diff --git a/package-lock.json b/package-lock.json index 8919f51892..fafa34b5d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@testing-library/react": "^13.3.0", "@types/cli-table": "^0.3.4", "@types/jmespath": "^0.15.2", + "@types/mocha": "^10.0.10", "@types/node": "^18.0.0", "@types/request": "^2.48.7", "@types/ws": "^8.2.0", @@ -73,6 +74,7 @@ "ts-loader": "^9.4.2", "tsconfig-paths-webpack-plugin": "^4.0.1", "tslib": "^2.3.1", + "tsx": "^4.21.0", "typedoc": "^0.24.7", "typescript": "^4.9.5", "vite": "^4.4.9", @@ -782,6 +784,23 @@ "node": "^14 || ^16 || ^17 || ^18 || ^19 || ^20" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -1038,6 +1057,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -1054,6 +1090,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -1070,6 +1123,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -1615,6 +1685,13 @@ "@types/node": "*" } }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.19.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.8.tgz", @@ -5232,6 +5309,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/getobject": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/getobject/-/getobject-1.0.2.tgz", @@ -8702,6 +8792,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -9932,6 +10032,442 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11709,6 +12245,13 @@ "jsdoc-type-pratt-parser": "~4.0.0" } }, + "@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "dev": true, + "optional": true + }, "@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -11821,6 +12364,13 @@ "dev": true, "optional": true }, + "@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "dev": true, + "optional": true + }, "@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -11828,6 +12378,13 @@ "dev": true, "optional": true }, + "@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "dev": true, + "optional": true + }, "@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -11835,6 +12392,13 @@ "dev": true, "optional": true }, + "@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "dev": true, + "optional": true + }, "@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -12239,6 +12803,12 @@ "@types/node": "*" } }, + "@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true + }, "@types/node": { "version": "18.19.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.8.tgz", @@ -14925,6 +15495,15 @@ "get-intrinsic": "^1.1.1" } }, + "get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, "getobject": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/getobject/-/getobject-1.0.2.tgz", @@ -17451,6 +18030,12 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true + }, "responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -18392,6 +18977,207 @@ } } }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + }, + "dependencies": { + "@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + } + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 85781a3802..e64fe72d5e 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@testing-library/react": "^13.3.0", "@types/cli-table": "^0.3.4", "@types/jmespath": "^0.15.2", + "@types/mocha": "^10.0.10", "@types/node": "^18.0.0", "@types/request": "^2.48.7", "@types/ws": "^8.2.0", @@ -129,6 +130,7 @@ "ts-loader": "^9.4.2", "tsconfig-paths-webpack-plugin": "^4.0.1", "tslib": "^2.3.1", + "tsx": "^4.21.0", "typedoc": "^0.24.7", "typescript": "^4.9.5", "vite": "^4.4.9", @@ -158,6 +160,7 @@ "test:playwright": "node test/support/runPlaywrightTests.js", "test:react": "vitest run", "test:package": "grunt test:package", + "test:uts": "npm run build:node && mocha --no-config --require tsx/cjs 'test/uts/**/*.test.ts'", "concat": "grunt concat", "build": "grunt build:all && npm run build:react", "build:node": "grunt build:node", diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index c470666bc8..ef3d2efe9e 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -608,7 +608,7 @@ class Auth { return new Promise((resolve, reject) => { let tokenRequestCallbackTimeoutExpired = false, timeoutLength = this.client.options.timeouts.realtimeRequestTimeout, - tokenRequestCallbackTimeout = setTimeout(() => { + tokenRequestCallbackTimeout = Platform.Config.setTimeout(() => { tokenRequestCallbackTimeoutExpired = true; const msg = 'Token request callback timed out after ' + timeoutLength / 1000 + ' seconds'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); @@ -617,7 +617,7 @@ class Auth { tokenRequestCallback!(resolvedTokenParams, (err, tokenRequestOrDetails, contentType) => { if (tokenRequestCallbackTimeoutExpired) return; - clearTimeout(tokenRequestCallbackTimeout); + Platform.Config.clearTimeout(tokenRequestCallbackTimeout); if (err) { Logger.logAction( diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 5586d36d87..3f6e51b6f4 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -193,7 +193,7 @@ class BaseClient { } getTimestampUsingOffset(): number { - return Date.now() + (this.serverTimeOffset || 0); + return Platform.Config.now() + (this.serverTimeOffset || 0); } isTimeOffsetSet(): boolean { diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 388338b3ab..c86345a982 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -9,6 +9,7 @@ import ChannelStateChange from './channelstatechange'; import ErrorInfo, { PartialErrorInfo } from '../types/errorinfo'; import * as API from '../../../../ably'; import ConnectionManager from '../transport/connectionmanager'; +import Platform from '../../platform'; import { StandardCallback } from '../../types/utils'; import BaseRealtime from './baserealtime'; import { ChannelOptions } from '../../types/channel'; @@ -940,7 +941,7 @@ class RealtimeChannel extends EventEmitter { startStateTimerIfNotRunning(): void { if (!this.stateTimer) { - this.stateTimer = setTimeout(() => { + this.stateTimer = Platform.Config.setTimeout(() => { Logger.logAction(this.logger, Logger.LOG_MINOR, 'RealtimeChannel.startStateTimerIfNotRunning', 'timer expired'); this.stateTimer = null; this.timeoutPendingState(); @@ -951,7 +952,7 @@ class RealtimeChannel extends EventEmitter { clearStateTimer(): void { const stateTimer = this.stateTimer; if (stateTimer) { - clearTimeout(stateTimer); + Platform.Config.clearTimeout(stateTimer as unknown as ReturnType); this.stateTimer = null; } } @@ -962,7 +963,7 @@ class RealtimeChannel extends EventEmitter { this.retryCount++; const retryDelay = Utils.getRetryTime(this.client.options.timeouts.channelRetryTimeout, this.retryCount); - this.retryTimer = setTimeout(() => { + this.retryTimer = Platform.Config.setTimeout(() => { /* If connection is not connected, just leave in suspended, a reattach * will be triggered once it connects again */ if (this.state === 'suspended' && this.connectionManager.state.sendEvents) { @@ -980,7 +981,7 @@ class RealtimeChannel extends EventEmitter { cancelRetryTimer(): void { if (this.retryTimer) { - clearTimeout(this.retryTimer as NodeJS.Timeout); + Platform.Config.clearTimeout(this.retryTimer as unknown as ReturnType); this.retryTimer = null; } } diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 5b9820e90e..9ada900839 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -10,6 +10,7 @@ import ChannelStateChange from './channelstatechange'; import { ErrCallback } from '../../types/utils'; import { PaginatedResult } from './paginatedresource'; import { PresenceMap, RealtimePresenceParams } from './presencemap'; +import Platform from '../../platform'; interface RealtimeHistoryParams { start?: number; @@ -401,7 +402,7 @@ class RealtimePresence extends EventEmitter { clientId: item.clientId, data: item.data, encoding: item.encoding, - timestamp: Date.now(), + timestamp: Platform.Config.now(), }); subscriptions.emit('leave', presence); }); diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index deb9d960ef..1046d91731 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -95,7 +95,7 @@ export class Rest { throw new ErrorInfo('Internal error (unexpected result type from GET /time)', 50000, 500); } /* calculate time offset only once for this device by adding to the prototype */ - this.client.serverTimeOffset = time - Date.now(); + this.client.serverTimeOffset = time - Platform.Config.now(); return time; } diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index a2f29fe730..d12fe04fd5 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -870,7 +870,7 @@ class ConnectionManager extends EventEmitter { return; } - const sinceLast = Date.now() - this.lastActivity; + const sinceLast = Platform.Config.now() - this.lastActivity; if (sinceLast > this.connectionStateTtl + (this.maxIdleInterval as number)) { Logger.logAction( this.logger, @@ -893,7 +893,7 @@ class ConnectionManager extends EventEmitter { if (recoveryKey) { this.setSessionRecoverData({ recoveryKey: recoveryKey, - disconnectedAt: Date.now(), + disconnectedAt: Platform.Config.now(), location: globalObject.location, clientId: this.realtime.auth.clientId, }); @@ -988,10 +988,10 @@ class ConnectionManager extends EventEmitter { 'ConnectionManager.startTransitionTimer()', 'clearing already-running timer', ); - clearTimeout(this.transitionTimer as number); + Platform.Config.clearTimeout(this.transitionTimer as unknown as ReturnType); } - this.transitionTimer = setTimeout(() => { + this.transitionTimer = Platform.Config.setTimeout(() => { if (this.transitionTimer) { this.transitionTimer = null; Logger.logAction( @@ -1008,14 +1008,14 @@ class ConnectionManager extends EventEmitter { cancelTransitionTimer(): void { Logger.logAction(this.logger, Logger.LOG_MINOR, 'ConnectionManager.cancelTransitionTimer()', ''); if (this.transitionTimer) { - clearTimeout(this.transitionTimer as number); + Platform.Config.clearTimeout(this.transitionTimer as unknown as ReturnType); this.transitionTimer = null; } } startSuspendTimer(): void { if (this.suspendTimer) return; - this.suspendTimer = setTimeout(() => { + this.suspendTimer = Platform.Config.setTimeout(() => { if (this.suspendTimer) { this.suspendTimer = null; Logger.logAction( @@ -1037,13 +1037,13 @@ class ConnectionManager extends EventEmitter { cancelSuspendTimer(): void { this.states.connecting.failState = 'disconnected'; if (this.suspendTimer) { - clearTimeout(this.suspendTimer as number); + Platform.Config.clearTimeout(this.suspendTimer as unknown as ReturnType); this.suspendTimer = null; } } startRetryTimer(interval: number): void { - this.retryTimer = setTimeout(() => { + this.retryTimer = Platform.Config.setTimeout(() => { Logger.logAction(this.logger, Logger.LOG_MINOR, 'ConnectionManager retry timer expired', 'retrying'); this.retryTimer = null; this.requestState({ state: 'connecting' }); @@ -1052,13 +1052,13 @@ class ConnectionManager extends EventEmitter { cancelRetryTimer(): void { if (this.retryTimer) { - clearTimeout(this.retryTimer as NodeJS.Timeout); + Platform.Config.clearTimeout(this.retryTimer as unknown as ReturnType); this.retryTimer = null; } } startWebSocketSlowTimer() { - this.webSocketSlowTimer = setTimeout(() => { + this.webSocketSlowTimer = Platform.Config.setTimeout(() => { Logger.logAction( this.logger, Logger.LOG_MINOR, @@ -1113,13 +1113,13 @@ class ConnectionManager extends EventEmitter { cancelWebSocketSlowTimer() { if (this.webSocketSlowTimer) { - clearTimeout(this.webSocketSlowTimer); + Platform.Config.clearTimeout(this.webSocketSlowTimer); this.webSocketSlowTimer = null; } } startWebSocketGiveUpTimer(transportParams: TransportParams) { - this.webSocketGiveUpTimer = setTimeout(() => { + this.webSocketGiveUpTimer = Platform.Config.setTimeout(() => { if (!this.wsCheckResult) { Logger.logAction( this.logger, @@ -1147,7 +1147,7 @@ class ConnectionManager extends EventEmitter { cancelWebSocketGiveUpTimer() { if (this.webSocketGiveUpTimer) { - clearTimeout(this.webSocketGiveUpTimer); + Platform.Config.clearTimeout(this.webSocketGiveUpTimer); this.webSocketGiveUpTimer = null; } } @@ -1215,11 +1215,11 @@ class ConnectionManager extends EventEmitter { if (retryImmediately) { const autoReconnect = () => { if (this.state === this.states.disconnected) { - this.lastAutoReconnectAttempt = Date.now(); + this.lastAutoReconnectAttempt = Platform.Config.now(); this.requestState({ state: 'connecting' }); } }; - const sinceLast = this.lastAutoReconnectAttempt && Date.now() - this.lastAutoReconnectAttempt + 1; + const sinceLast = this.lastAutoReconnectAttempt && Platform.Config.now() - this.lastAutoReconnectAttempt + 1; if (sinceLast && sinceLast < 1000) { Logger.logAction( this.logger, @@ -1231,7 +1231,7 @@ class ConnectionManager extends EventEmitter { (1000 - sinceLast) + 'ms before trying again', ); - setTimeout(autoReconnect, 1000 - sinceLast); + Platform.Config.setTimeout(autoReconnect, 1000 - sinceLast); } else { Platform.Config.nextTick(autoReconnect); } @@ -1891,7 +1891,7 @@ class ConnectionManager extends EventEmitter { Logger.logAction(this.logger, Logger.LOG_MINOR, 'ConnectionManager.ping()', 'transport = ' + transport); - const pingStart = Date.now(); + const pingStart = Platform.Config.now(); const id = Utils.cheapRandStr(); return Utils.withTimeoutAsync( @@ -1899,7 +1899,7 @@ class ConnectionManager extends EventEmitter { const onHeartbeat = (responseId: string) => { if (responseId === id) { transport.off('heartbeat', onHeartbeat); - resolve(Date.now() - pingStart); + resolve(Platform.Config.now() - pingStart); } }; transport.on('heartbeat', onHeartbeat); diff --git a/src/common/lib/transport/transport.ts b/src/common/lib/transport/transport.ts index afa2db1945..70e408cc93 100644 --- a/src/common/lib/transport/transport.ts +++ b/src/common/lib/transport/transport.ts @@ -113,7 +113,7 @@ abstract class Transport extends EventEmitter { this.isFinished = true; this.isConnected = false; this.maxIdleInterval = null; - clearTimeout(this.idleTimer ?? undefined); + Platform.Config.clearTimeout((this.idleTimer ?? undefined) as unknown as ReturnType); this.idleTimer = null; this.emit(event, err); this.dispose(); @@ -270,13 +270,13 @@ abstract class Transport extends EventEmitter { if (!this.maxIdleInterval) { return; } - this.lastActivity = this.connectionManager.lastActivity = Date.now(); + this.lastActivity = this.connectionManager.lastActivity = Platform.Config.now(); this.setIdleTimer(this.maxIdleInterval + 100); } setIdleTimer(timeout: number): void { if (!this.idleTimer) { - this.idleTimer = setTimeout(() => { + this.idleTimer = Platform.Config.setTimeout(() => { this.onIdleTimerExpire(); }, timeout); } @@ -287,7 +287,7 @@ abstract class Transport extends EventEmitter { throw new Error('Transport.onIdleTimerExpire(): lastActivity/maxIdleInterval not set'); } this.idleTimer = null; - const sinceLast = Date.now() - this.lastActivity; + const sinceLast = Platform.Config.now() - this.lastActivity; const timeRemaining = this.maxIdleInterval - sinceLast; if (timeRemaining <= 0) { const msg = 'No activity seen from realtime in ' + sinceLast + 'ms; assuming connection has dropped'; @@ -310,12 +310,12 @@ abstract class Transport extends EventEmitter { let transportAttemptTimer: NodeJS.Timeout | number; const errorCb = function (this: { event: string }, err: ErrorInfo) { - clearTimeout(transportAttemptTimer); + Platform.Config.clearTimeout(transportAttemptTimer as unknown as ReturnType); callback({ event: this.event, error: err }); }; const realtimeRequestTimeout = connectionManager.options.timeouts.realtimeRequestTimeout; - transportAttemptTimer = setTimeout(() => { + transportAttemptTimer = Platform.Config.setTimeout(() => { transport.off(['preconnect', 'disconnected', 'failed']); transport.dispose(); errorCb.call( @@ -332,7 +332,7 @@ abstract class Transport extends EventEmitter { 'Transport.tryConnect()', 'viable transport ' + transport, ); - clearTimeout(transportAttemptTimer); + Platform.Config.clearTimeout(transportAttemptTimer as unknown as ReturnType); transport.off(['failed', 'disconnected'], errorCb); callback(null, transport); }); diff --git a/src/common/lib/util/logger.ts b/src/common/lib/util/logger.ts index 26c01b4212..a9c4fe8e00 100644 --- a/src/common/lib/util/logger.ts +++ b/src/common/lib/util/logger.ts @@ -25,7 +25,7 @@ function pad(timeSegment: number, three?: number) { function getHandler(logger: Function): Function { return Platform.Config.logTimestamps ? function (msg: unknown) { - const time = new Date(); + const time = new Date(Platform.Config.now()); logger( pad(time.getHours()) + ':' + diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index 9c158442cd..e41775c8f6 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -476,7 +476,10 @@ export function throwMissingPluginError(pluginName: keyof ModularPlugins): never export async function withTimeoutAsync(promise: Promise, timeout = 5000, err = 'Timeout expired'): Promise { const e = new ErrorInfo(err, 50000, 500); - return Promise.race([promise, new Promise((_resolve, reject) => setTimeout(() => reject(e), timeout))]); + return Promise.race([ + promise, + new Promise((_resolve, reject) => Platform.Config.setTimeout(() => reject(e), timeout)), + ]); } type NonFunctionKeyNames = { [P in keyof A]: A[P] extends Function ? never : P }[keyof A]; diff --git a/src/common/types/IPlatformConfig.d.ts b/src/common/types/IPlatformConfig.d.ts index 255b948ef4..11c0624dd3 100644 --- a/src/common/types/IPlatformConfig.d.ts +++ b/src/common/types/IPlatformConfig.d.ts @@ -14,6 +14,9 @@ export interface ICommonPlatformConfig { supportsBinary: boolean; preferBinary: boolean; nextTick: process.nextTick; + setTimeout: (handler: () => void, timeout?: number) => ReturnType; + clearTimeout: (id: ReturnType | null | undefined) => void; + now: () => number; inspect: (value: unknown) => string; stringByteSize: Buffer.byteLength; getRandomArrayBuffer: (byteLength: number) => Promise; diff --git a/src/common/types/http.ts b/src/common/types/http.ts index 964ae1424e..b135404adf 100644 --- a/src/common/types/http.ts +++ b/src/common/types/http.ts @@ -183,7 +183,7 @@ export class Http { const currentFallback = client._currentFallback; if (currentFallback) { - if (currentFallback.validUntil > Date.now()) { + if (currentFallback.validUntil > Platform.Config.now()) { /* Use stored fallback */ const result = await this.doUri(method, uriFromHost(currentFallback.host), headers, body, params); if (result.error && this.platformHttp.shouldFallback(result.error as ErrnoException)) { @@ -205,14 +205,14 @@ export class Http { return this.doUri(method, uriFromHost(hosts[0]), headers, body, params); } - let tryAHostStartedAt: Date | null = null; + let tryAHostStartedAt: number | null = null; const tryAHost = async (candidateHosts: Array, persistOnSuccess?: boolean): Promise => { const host = candidateHosts.shift(); - tryAHostStartedAt = tryAHostStartedAt ?? new Date(); + tryAHostStartedAt = tryAHostStartedAt ?? Platform.Config.now(); const result = await this.doUri(method, uriFromHost(host as string), headers, body, params); if (result.error && this.platformHttp.shouldFallback(result.error as ErrnoException) && candidateHosts.length) { // TO3l6 - const elapsedTime = Date.now() - tryAHostStartedAt.getTime(); + const elapsedTime = Platform.Config.now() - tryAHostStartedAt; if (elapsedTime > client.options.timeouts.httpMaxRetryDuration) { return { error: new ErrorInfo( @@ -229,7 +229,7 @@ export class Http { /* RSC15f */ client._currentFallback = { host: host as string, - validUntil: Date.now() + client.options.timeouts.fallbackRetryTimeout, + validUntil: Platform.Config.now() + client.options.timeouts.fallbackRetryTimeout, }; } return result; diff --git a/src/platform/nativescript/config.js b/src/platform/nativescript/config.js index db739aaf30..fb7d4e50ad 100644 --- a/src/platform/nativescript/config.js +++ b/src/platform/nativescript/config.js @@ -32,6 +32,9 @@ var Config = { nextTick: function (f) { setTimeout(f, 0); }, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + now: Date.now, addEventListener: null, inspect: JSON.stringify, stringByteSize: function (str) { diff --git a/src/platform/nodejs/config.ts b/src/platform/nodejs/config.ts index fc116ce086..c5d682603d 100644 --- a/src/platform/nodejs/config.ts +++ b/src/platform/nodejs/config.ts @@ -13,6 +13,9 @@ const Config: IPlatformConfig = { supportsBinary: true, preferBinary: true, nextTick: process.nextTick, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + now: Date.now, inspect: util.inspect, stringByteSize: Buffer.byteLength, inherits: util.inherits, diff --git a/src/platform/react-native/config.ts b/src/platform/react-native/config.ts index d65fdccc89..3e63335f20 100644 --- a/src/platform/react-native/config.ts +++ b/src/platform/react-native/config.ts @@ -22,6 +22,9 @@ export default function (bufferUtils: typeof BufferUtils): IPlatformConfig { typeof global.queueMicrotask === 'function' ? (f: () => void) => global.queueMicrotask(f) : (f: () => void) => Promise.resolve().then(f), + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + now: Date.now, addEventListener: null, inspect: JSON.stringify, stringByteSize: function (str: string) { diff --git a/src/platform/web/config.ts b/src/platform/web/config.ts index b74a7b8305..e302fd9a12 100644 --- a/src/platform/web/config.ts +++ b/src/platform/web/config.ts @@ -62,6 +62,9 @@ const Config: IPlatformConfig = { typeof globalObject.queueMicrotask === 'function' ? (f: () => void) => globalObject.queueMicrotask(f) : (f: () => void) => Promise.resolve().then(f), + setTimeout: globalObject.setTimeout.bind(globalObject), + clearTimeout: globalObject.clearTimeout.bind(globalObject), + now: Date.now, addEventListener: globalObject.addEventListener, inspect: JSON.stringify, stringByteSize: function (str: string) { diff --git a/test/uts/README.md b/test/uts/README.md new file mode 100644 index 0000000000..77c4d66a69 --- /dev/null +++ b/test/uts/README.md @@ -0,0 +1,170 @@ +# UTS Tests for ably-js + +Universal Test Specification (UTS) tests — portable tests translated from the pseudocode specs in `specification/uts/`. + +## Running + +```bash +npm run test:uts +``` + +This builds the Node.js bundle and runs all UTS tests via mocha. UTS tests are isolated from the main test suite (no shared_helper, no sandbox setup). + +## Architecture + +UTS tests run against the **Node.js build** (`build/ably-node.js`) with mock implementations injected at the Platform level: + +- **HTTP** is mocked by replacing `Platform.Http` +- **WebSocket** is mocked by replacing `Platform.Config.WebSocket` +- **Timers/clock** are mocked by replacing `Platform.Config.setTimeout`, `.clearTimeout`, `.now` + +No global patching — only the Platform singleton is modified, so mocha's own timers and I/O work normally. + +## Mock HTTP Client + +The `MockHttpClient` implements the UTS mock HTTP spec. It maps ably-js's single `doUri()` call onto the UTS two-phase model (connection attempt + HTTP request). + +### Handler pattern (recommended for most tests) + +```typescript +import { MockHttpClient } from '../mock_http'; +import { installMockHttp, uninstallMockHttp, Ably } from '../helpers'; + +const captured: any[] = []; +const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, +}); + +installMockHttp(mock); +const client = new Ably.Rest({ key: 'app.key:secret' }); +const time = await client.time(); +// captured[0].method === 'GET' +// captured[0].path === '/time' +uninstallMockHttp(); +``` + +### Await pattern (for advanced scenarios) + +```typescript +import { MockHttpClient } from '../mock_http'; +import { installMockHttp, uninstallMockHttp, Ably } from '../helpers'; + +const mock = new MockHttpClient(); +installMockHttp(mock); + +const client = new Ably.Rest({ key: 'app.key:secret' }); +const timePromise = client.time(); + +const conn = await mock.await_connection_attempt(); +conn.respond_with_success(); + +const req = await mock.await_request(); +assert(req.headers['X-Ably-Version']); +req.respond_with(200, [1704067200000]); + +const time = await timePromise; +uninstallMockHttp(); +``` + +### PendingConnection methods + +| Method | Effect | +| -------------------------- | ---------------------------------------- | +| `respond_with_success()` | Connection succeeds, allows HTTP request | +| `respond_with_refused()` | TCP connection refused | +| `respond_with_timeout()` | Connection times out | +| `respond_with_dns_error()` | DNS resolution fails | + +### PendingRequest methods + +| Method | Effect | +| -------------------------------------- | ---------------------------------- | +| `respond_with(status, body, headers?)` | Return HTTP response | +| `respond_with_timeout()` | Request times out after connection | + +### PendingRequest properties + +| Property | Description | +| --------- | ----------------------------- | +| `method` | HTTP method (GET, POST, etc.) | +| `url` | Parsed URL object | +| `path` | URL pathname (e.g., `/time`) | +| `headers` | Request headers | +| `body` | Request body | + +## Fake Timers + +For tests that need to control time (timeouts, retries, etc.): + +```typescript +import { enableFakeTimers, restoreAll } from '../helpers'; + +const clock = enableFakeTimers(); +// Platform.Config.now() returns 0 +// Platform.Config.setTimeout callbacks are queued + +clock.tick(5000); // advance 5s, fire expired timers synchronously +await clock.tickAsync(5000); // same but yields between timer firings + +clock.uninstall(); // restore real timers +``` + +Maps to UTS pseudocode: + +- `enable_fake_timers()` → `enableFakeTimers()` +- `ADVANCE_TIME(ms)` → `clock.tick(ms)` or `clock.tickAsync(ms)` + +## Writing a new test file + +```typescript +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/my-feature', function () { + let mock: MockHttpClient; + + beforeEach(function () { + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}), + }); + installMockHttp(mock); + }); + + afterEach(function () { + restoreAll(); + }); + + it('RSC99 - does something', async function () { + const client = new Ably.Rest({ key: 'app.key:secret' }); + // ... test ... + }); +}); +``` + +## Directory structure + +``` +test/uts/ + README.md # This file + helpers.ts # install/uninstall, FakeClock, Ably re-export + mock_http.ts # MockHttpClient (PendingConnection, PendingRequest) + mock_websocket.ts # MockWebSocket (PendingWSConnection, MockWSInstance) + deviations.md # Known spec/implementation deviations + rest/ # REST API tests + time.test.ts # RSC16 — time() tests + ... # (37 test files) + realtime/ # Realtime API tests + time.test.ts # RTC6a — RealtimeClient#time proxy tests + client/ # Realtime client tests + client_options.test.ts # RSC1, RTC12 + realtime_client.test.ts # RTC1a-f, RTC2-4, RTC13-17 + realtime_request.test.ts # RTC9 + realtime_stats.test.ts # RTC5 + realtime_timeouts.test.ts # RTC7 +``` diff --git a/test/uts/deviations.md b/test/uts/deviations.md new file mode 100644 index 0000000000..2fd1030955 --- /dev/null +++ b/test/uts/deviations.md @@ -0,0 +1,250 @@ +# UTS Test Deviations + +Tracks confirmed ably-js non-compliance with the Ably spec. Each entry corresponds to a test that either fails or was adapted to assert ably-js's actual behavior instead of the spec requirement. + +## Failing Tests + +### client_id: RSA7b - auth.clientId not derived from TokenDetails (REST) + +**Spec (RSA7b)**: "The clientId attribute of the Auth object is derived from the tokenDetails that are returned from an explicit auth request, or from the authCallback." + +**ably-js behavior**: For REST clients, `auth.clientId` is only set from `ClientOptions.clientId` (via `_userSetClientId` during construction). It is NOT extracted from: + +- `tokenDetails.clientId` passed in the constructor +- `TokenDetails.clientId` returned by `authCallback` +- `TokenDetails.clientId` returned by `authorize()` + +The `_uncheckedSetClientId` method exists but is only called from the Realtime connectionManager (on CONNECTED), never from REST token acquisition paths. + +**Tests affected** (5 failures): + +- `RSA7b - clientId from TokenDetails` — `auth.clientId` is undefined instead of `'token-client-id'` +- `RSA7b - clientId from authCallback TokenDetails` — `auth.clientId` is undefined instead of `'callback-client-id'` +- `RSA7 - clientId updated after authorize()` — `auth.clientId` is undefined instead of `'client-1'`/`'client-2'` +- `RSA12 - Wildcard clientId` — `auth.clientId` is undefined instead of `'*'` +- `RSA7 - case 5: clientId inherited from token` — `auth.clientId` is undefined instead of `'token-client'` + +**Root cause**: `_saveTokenOptions()` and `_ensureValidAuthCredentials()` store `tokenDetails` but never call `_uncheckedSetClientId(tokenDetails.clientId)`. + +--- + +### token_renewal: RSA4b - Authorization header overwritten on retry + +**Spec (RSA4b/RSC10)**: When a REST request fails with a token error (40140-40149), the library should obtain a new token and retry the request with the new token's authorization header. + +**ably-js behavior**: The retry sends the **old** token's authorization header instead of the new one. In `Resource.do()`, after a token error: + +```javascript +await client.auth.authorize(null, null); +return withAuthDetails(client, headers, params, doRequest); +``` + +The `headers` parameter passed to `withAuthDetails` is the `doRequest` function parameter — the **merged** headers from the first `withAuthDetails` call, which already contains `authorization: 'Bearer '`. Then `withAuthDetails` does: + +```javascript +const authHeaders = await client.auth.getAuthHeaders(); +return opCallback(Utils.mixin(authHeaders, headers), params); +``` + +`Utils.mixin(newAuthHeaders, oldMergedHeaders)` copies the old `authorization` from `oldMergedHeaders` into `newAuthHeaders`, overwriting the new token's header. + +**Consequences**: + +1. The retry always sends the old (expired) token +2. Combined with the lack of a retry limit (see below), this causes an infinite loop + +**Tests affected**: + +- `RSA4b - renewal on 40142 error` — `captured[1].headers.authorization` has the old token instead of the renewed one. +- `RSC10 - transparent retry after renewal` — same symptom: the retried request carries the old token's authorization header. + +**Root cause**: `src/common/lib/client/resource.ts` line ~347 — the retry should pass the original (pre-auth) headers to `withAuthDetails`, not the merged headers that include the old `authorization`. + +--- + +### token_renewal: RSA4b - No renewal retry limit + +**Spec (RSA4b)**: Token renewal should retry at most once per request. If the renewed token is also rejected, the error should propagate. + +**ably-js behavior**: The retry loop in `Resource.do()` is unbounded — on each token error, it calls `authorize()` and retries recursively with no counter. Combined with the header-overwrite bug above, this causes an infinite loop and eventual OOM when the server persistently returns token errors. + +**Test**: `RSA4b - renewal limit` — the authCallback caps at 3 responses to prevent OOM. Per spec, only 2 callbacks should occur (initial + 1 renewal). + +--- + +### annotations: RSAN1a3 - type validation missing + +**Spec (RSAN1a3)**: "The SDK must validate that the user supplied a `type`. All other fields are optional." Should throw error 40003. + +**ably-js behavior**: `constructValidateAnnotation()` does not validate that `type` is present. Annotation is published without a type, and the request succeeds. + +**Test**: `RSAN1a3 - type required` — asserts spec behavior (throw with code 40003). Currently fails. + +--- + +### annotations: RSAN1c4 - idempotent IDs not generated for annotations + +**Spec (RSAN1c4)**: "If `idempotentRestPublishing` is enabled and the annotation has an empty `id`, the SDK should generate a base64-encoded random string, append `:0`, and set it as the `Annotation.id`." + +**ably-js behavior**: `RestAnnotations.publish()` does not generate idempotent IDs. Only `RestChannel.publish()` (for messages) generates them. The annotation's `id` field is not set. + +**Test**: `RSAN1c4 - idempotent ID generated` — asserts spec behavior (id in `:0` format). Currently fails. + +--- + +### rest_client: RSC7c - addRequestIds not implemented + +**Spec (RSC7c)**: "When the `addRequestIds` option is set to true, the library must add a `request_id` query parameter to all REST requests." + +**ably-js behavior**: The `addRequestIds` option is accepted and stored in `client.options` but has no effect. No `request_id` parameter is added to any requests. There is no code referencing this option in the built bundle. + +**Test**: `RSC7c - request_id query param when addRequestIds is true` — fails because `request_id` is null. + +--- + +### fallback: RSC15l - request timeout does not trigger fallback + +**Spec (RSC15l)**: When a request times out after the connection is established (request-level timeout), the client should retry on a fallback host, just as it does for connection-level timeouts. + +**ably-js behavior**: Request-level timeouts propagate as errors without triggering fallback retry. Only connection-level errors (refused, DNS, timeout before connection) and HTTP 500-504 trigger fallback. + +**Test**: `RSC15l - request timeout triggers fallback` — asserts spec behavior. Currently fails. + +--- + +### fallback: RSC15l4 - CloudFront Server header not detected + +**Spec (RSC15l4)**: When a response includes `Server: CloudFront` header with status >= 400, the client should treat it as a server error and retry on a fallback host. + +**ably-js behavior**: `shouldFallback` in `http.ts` only checks for specific errno codes and HTTP 500-504. It does not inspect the `Server` response header. CloudFront errors with 4xx status codes are treated as non-retryable client errors. + +**Test**: `RSC15l4 - CloudFront Server header triggers fallback` — asserts spec behavior. Currently fails. + +--- + +### fallback: REC1b2 - IPv6 endpoint address not bracketed + +**Spec (REC1b2)**: When `endpoint` is an IPv6 address (e.g., `::1`), the library should treat it as an explicit hostname. + +**ably-js behavior**: `getPrimaryDomainFromEndpoint('::1')` returns `::1` (correct via `isFqdnIpOrLocalhost`), but URL construction produces `https://::1:443/time` instead of `https://[::1]:443/time`. The missing brackets cause an "Invalid URI" error. + +**Test**: `REC1b2 - endpoint as IPv6 address` — asserts spec behavior. Currently fails. + +--- + +## Adapted Tests + +Tests that pass but were adapted to assert ably-js's actual behavior instead of the spec requirement. These document genuine deviations where fixing the test to match the spec would cause a failure. + +### revoke_tokens: RSA17c - Response format pass-through + +**Spec (RSA17c)**: UTS spec expects the server to return a plain array of per-target results, and the client library to compute `successCount`, `failureCount`, and `results` from the array. + +**ably-js behavior**: `revokeTokens()` passes through the server response body as-is. The mock returns the pre-computed `{successCount, failureCount, results}` object, matching the actual Ably REST API response format. Additionally, `revokeTokens()` throws on HTTP 400 responses — the `batchResponse` data containing per-target success/failure results is discarded. + +**Tests affected**: RSA17c, RSA17c_2, RSA17c_3, TRF2_1. + +--- + +### options_types: AO2 - authMethod default not stored + +**Spec (AO2)**: `authMethod` defaults to 'GET' and should be accessible on the auth options object. + +**ably-js behavior**: When `authMethod` is not explicitly set, `auth.authOptions.authMethod` is `undefined`. The GET default is applied at HTTP request time, not stored in the options. + +**Test**: `AO2 - authMethod defaults to GET` — accepts both `'GET'` and `undefined`. + +--- + +### client_options: RSC1b - wrong error code for missing credentials + +**Spec (RSC1b)**: "If invalid arguments are provided such as no API key, no token and no means to create a token, then this will result in an error with error code 40106." + +**ably-js behavior**: Uses error code 40160 instead of 40106. Additionally, `{ useTokenAuth: true }` alone throws with no error code set. + +**Test**: `RSC1b - no credentials raises error` — asserts 40160 instead of spec's 40106. + +--- + +### connection_ping: RTN13d - ping does not defer in non-connected states + +**Spec (RTN13d)**: "If the connection is not in the CONNECTED state when ping() is called, the ping is deferred until the connection reaches a state that can resolve it (CONNECTED, FAILED, CLOSED, SUSPENDED)." + +**ably-js behavior**: `ping()` immediately rejects with "not connected" when called in CONNECTING or DISCONNECTED state. There is no deferral mechanism. `ConnectionManager.ping()` checks `this.state.state !== 'connected'` and throws immediately. + +**Test**: RTN13d tests rewritten to assert immediate rejection instead of deferral. + +--- + +### channel_publish: RTL6i3 / publish: RSL1e - null fields included in wire JSON + +**Spec (RTL6i3/RSL1e)**: "If any of the values are null, then key is not sent to Ably i.e. a payload with a null value for data would be sent as follows `{ "name": "click" }`" + +**ably-js behavior**: When `data` is `null`/`undefined`, ably-js includes it as `"data": null` in the JSON wire format instead of omitting the key. Similarly for `name`. + +**Root cause**: Message serialization in `src/common/lib/types/message.ts` does not strip null/undefined values before `JSON.stringify`. + +**Tests affected**: `RTL6i3 - null name/data fields handled correctly`, `RSL1e - null name omitted from body`. + +--- + +### channels_collection: RTS4a - release throws on attached channels + +**Spec (RTS4a)**: "Detaches the channel and then releases the channel resource i.e. it's deleted and can then be garbage collected" + +**ably-js behavior**: `channels.release()` throws error 90001 ("Channel operation failed as channel state is attached") when called on an attached channel, instead of detaching first. + +**Test**: `RTS4a - release throws on attached channel (deviation)` — asserts the throw with code 90001. + +--- + +### batch_presence: BAR2/BGF2/RSC24_Mixed - mixed/failure results not normalised + +**Spec (BAR2, BGF2, RSC24)**: When the server returns HTTP 400 with `{error, batchResponse}` for mixed or all-failure batch presence results, the SDK normalises the response into `{successCount, failureCount, results}`. + +**ably-js behavior**: `batchPresence()` calls `Resource.get()` with `throwError=true`. Any HTTP 400 response sets `result.err`, which is thrown. The `batchResponse` data containing per-channel success/failure results is discarded. + +**Tests affected**: BAR2_1, BAR2_3, BGF2_1, RSC24_Mixed_1 — all assert that ably-js throws error 40020. + +--- + +### batch_publish: RSC22d - batchPublish does not generate idempotent IDs + +**Spec (RSC22d)**: "If `idempotentRestPublishing` is enabled, then RSL1k1 should be applied (to each `BatchPublishSpec` separately)." + +**ably-js behavior**: `batchPublish()` passes `BatchPublishSpec` objects directly to `Resource.post('/messages')` without any message processing. Unlike `RestChannel.publish()`, which generates idempotent IDs via the `allEmptyIds()` / `idempotentRestPublishing` code path, `batchPublish()` sends messages exactly as provided by the caller. No `id` fields are added. + +**Test**: `RSC22d - batch publish does not generate idempotent IDs (deviation)` — asserts messages lack `id` property. + +--- + +### presence_message_types: TP3h - memberKey not exposed + +**Spec (TP3h)**: `memberKey` is a "string function that combines the `connectionId` and `clientId` ensuring multiple connected clients with the same clientId are uniquely identifiable." It should be a method on `PresenceMessage`. + +**ably-js behavior**: `memberKey` is not a method on `PresenceMessage`. It is computed internally as a lambda `(item) => item.clientId + ':' + item.connectionId` passed to `PresenceMap`, but not accessible to callers. + +**Test**: `TP3h - memberKey` — falls back to asserting the component fields (`connectionId`, `clientId`) instead. + +--- + +## Mock Infrastructure Limitations + +### MsgPack encoding/decoding not supported + +The UTS mock HTTP infrastructure (`test/uts/mock_http.ts`) operates at the JSON level — `PendingRequest.respond_with()` JSON-stringifies response bodies and `PendingRequest.body` contains the JSON-parsed request body. It has no mechanism to encode/decode msgpack binary format. + +**Tests affected (10 skipped)**: + +- `RSL4c` — binary data with msgpack protocol (message_encoding.test.ts) +- `RSL6` — msgpack bin type decoded to Buffer (message_encoding.test.ts) +- `RSL6` — msgpack str type decoded to string (message_encoding.test.ts) +- `RSC8a` — default msgpack protocol Content-Type (rest_client.test.ts) +- `RSC8d` — mismatched Content-Type response (rest_client.test.ts) +- `RSC8e` — unsupported Content-Type response (rest_client.test.ts) +- `RSC8` — msgpack error response decoding (rest_client.test.ts) +- `RSC19c` — msgpack request headers (request.test.ts) +- `RSC19c` — msgpack request body encoding (request.test.ts) +- `RSC19c` — msgpack response decoding (request.test.ts) + +These tests are present as `this.skip()` stubs. To implement them, the mock would need msgpack serialization/deserialization support (e.g., adding `@ably/msgpack-js` as a dev dependency and extending PendingRequest/PendingConnection). diff --git a/test/uts/helpers.ts b/test/uts/helpers.ts new file mode 100644 index 0000000000..f5ea68b762 --- /dev/null +++ b/test/uts/helpers.ts @@ -0,0 +1,249 @@ +/** + * UTS test helpers — mock installation/teardown and fake timers. + * + * These helpers manage the Platform singleton state, replacing HTTP, + * WebSocket, and timer implementations with test doubles. + */ + +// Import from the internal Node.js source so consumers get the real internal +// types rather than the trimmed-down public surface in ably.d.ts. The +// side-effect import wires up Platform with the Node-specific Http, Config, +// Crypto, etc. — equivalent to loading build/ably-node.js. +import '../../src/platform/nodejs'; +import { DefaultRest } from '../../src/common/lib/client/defaultrest'; +import { DefaultRealtime } from '../../src/common/lib/client/defaultrealtime'; +import ErrorInfo from '../../src/common/lib/types/errorinfo'; +import { makeFromDeserializedWithDependencies as makeProtocolMessageFromDeserialized } from '../../src/common/lib/types/protocolmessage'; + +const Ably = { + Rest: DefaultRest, + Realtime: DefaultRealtime, + ErrorInfo, + makeProtocolMessageFromDeserialized, +}; + +const Platform = DefaultRest.Platform; + +// Saved originals for teardown +let _savedHttp: any = null; +let _savedWebSocket: any = null; +let _savedSetTimeout: any = null; +let _savedClearTimeout: any = null; +let _savedNow: any = null; + +// Tracked clients for cleanup — ensures timers are released even if a test crashes +const _trackedClients: any[] = []; + +/** + * Install a MockHttpClient as the platform HTTP implementation. + * Call uninstallMockHttp() in afterEach to restore the original. + */ +function installMockHttp(mockHttpClient: { asPlatformHttp(): any }): void { + if (_savedHttp) throw new Error('Mock HTTP already installed — call uninstallMockHttp() first'); + _savedHttp = Platform.Http; + Platform.Http = mockHttpClient.asPlatformHttp(); +} + +/** + * Restore the original platform HTTP implementation. + */ +function uninstallMockHttp(): void { + if (_savedHttp) { + Platform.Http = _savedHttp; + _savedHttp = null; + } +} + +/** + * Install a mock WebSocket constructor. + * Call uninstallMockWebSocket() in afterEach to restore the original. + */ +function installMockWebSocket(mockWsConstructor: any): void { + if (_savedWebSocket) throw new Error('Mock WebSocket already installed'); + _savedWebSocket = Platform.Config.WebSocket; + Platform.Config.WebSocket = mockWsConstructor; +} + +/** + * Restore the original platform WebSocket constructor. + */ +function uninstallMockWebSocket(): void { + if (_savedWebSocket) { + Platform.Config.WebSocket = _savedWebSocket; + _savedWebSocket = null; + } +} + +interface FakeTimer { + id: number; + fn: () => void; + fireAt: number; +} + +/** + * FakeClock — deterministic timer replacement for Platform.Config. + * + * Replaces Platform.Config.setTimeout, clearTimeout, and now with + * a fake clock that can be advanced manually. No global patching — + * only Platform.Config is affected, so mocha's own timers work normally. + * + * Usage: + * const clock = enableFakeTimers(); + * // ... trigger operations that use Platform.Config.setTimeout ... + * clock.tick(5000); // advance 5 seconds, firing expired timers + * clock.uninstall(); // restore real timers + */ +class FakeClock { + private _now: number; + private _timers: FakeTimer[]; + private _nextId: number; + + constructor() { + this._now = 1000000; // Must be non-zero: ably-js uses !lastActivity to check "not set" and 0 is falsy + this._timers = []; + this._nextId = 1; + } + + /** Current fake time in ms */ + get now(): number { + return this._now; + } + + /** Schedule a callback after `ms` milliseconds of fake time */ + setTimeout(fn: () => void, ms?: number): number { + const id = this._nextId++; + const fireAt = this._now + (ms || 0); + this._timers.push({ id, fn, fireAt }); + this._timers.sort((a, b) => a.fireAt - b.fireAt); + return id; + } + + /** Cancel a scheduled timer */ + clearTimeout(id: number): void { + this._timers = this._timers.filter((t) => t.id !== id); + } + + /** + * Advance fake time by `ms` milliseconds, firing any timers that expire + * during the advance. Timers fire in chronological order. + */ + tick(ms: number): void { + const targetTime = this._now + ms; + while (this._timers.length > 0 && this._timers[0].fireAt <= targetTime) { + const timer = this._timers.shift()!; + this._now = timer.fireAt; + timer.fn(); + } + this._now = targetTime; + } + + /** + * Async version of tick that yields to the event loop between timer firings. + * Use this when timer callbacks schedule microtasks or promises that must + * settle before the next timer fires. + */ + async tickAsync(ms: number): Promise { + const targetTime = this._now + ms; + while (this._timers.length > 0 && this._timers[0].fireAt <= targetTime) { + const timer = this._timers.shift()!; + this._now = timer.fireAt; + timer.fn(); + // Yield to the event loop (not just the microtask queue) so that all + // chained process.nextTick callbacks (e.g. mock WebSocket error/close + // events) are fully drained before the next fake timer fires. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + this._now = targetTime; + } + + /** Install this clock on Platform.Config */ + install(): this { + if (_savedSetTimeout) throw new Error('Fake timers already installed'); + _savedSetTimeout = Platform.Config.setTimeout; + _savedClearTimeout = Platform.Config.clearTimeout; + _savedNow = Platform.Config.now; + // The fake clock returns numeric ids rather than NodeJS.Timeout objects; + // since clearTimeout is also faked, the id only flows back through our + // own implementation, so the type mismatch is purely cosmetic. + Platform.Config.setTimeout = this.setTimeout.bind(this) as unknown as typeof Platform.Config.setTimeout; + Platform.Config.clearTimeout = this.clearTimeout.bind(this) as unknown as typeof Platform.Config.clearTimeout; + Platform.Config.now = () => this._now; + return this; + } + + /** Uninstall and restore real timers */ + uninstall(): void { + if (_savedSetTimeout) { + Platform.Config.setTimeout = _savedSetTimeout; + Platform.Config.clearTimeout = _savedClearTimeout; + Platform.Config.now = _savedNow; + _savedSetTimeout = null; + _savedClearTimeout = null; + _savedNow = null; + } + } +} + +/** + * Enable fake timers on Platform.Config. + * Returns a FakeClock instance. Call clock.uninstall() in afterEach. + * + * Maps to UTS pseudocode: enable_fake_timers() + */ +function enableFakeTimers(): FakeClock { + const clock = new FakeClock(); + clock.install(); + return clock; +} + +/** + * Register a client for automatic cleanup in restoreAll(). + * Call this after creating any Ably.Rest or Ably.Realtime client in a test. + * restoreAll() will close all tracked clients, preventing timer leaks + * even if the test throws before reaching its own cleanup code. + */ +function trackClient(client: any): void { + _trackedClients.push(client); +} + +/** + * Restore all mocks. Call this in afterEach to clean up everything. + */ +function restoreAll(): void { + // Close all tracked clients first (before restoring mocks/timers) + // so their internal timers are cancelled while mocks are still in place. + while (_trackedClients.length > 0) { + const client = _trackedClients.pop(); + try { + if (typeof client.close === 'function') { + client.close(); + } + } catch (_) { + // Ignore errors during cleanup + } + } + uninstallMockHttp(); + uninstallMockWebSocket(); + // Restore fake timers if installed + if (_savedSetTimeout) { + Platform.Config.setTimeout = _savedSetTimeout; + Platform.Config.clearTimeout = _savedClearTimeout; + Platform.Config.now = _savedNow; + _savedSetTimeout = null; + _savedClearTimeout = null; + _savedNow = null; + } +} + +export { + Ably, + Platform, + installMockHttp, + uninstallMockHttp, + installMockWebSocket, + uninstallMockWebSocket, + enableFakeTimers, + FakeClock, + trackClient, + restoreAll, +}; diff --git a/test/uts/mock_http.ts b/test/uts/mock_http.ts new file mode 100644 index 0000000000..199c6d373b --- /dev/null +++ b/test/uts/mock_http.ts @@ -0,0 +1,331 @@ +/** + * Mock HTTP infrastructure for UTS tests. + * + * Implements the IPlatformHttpStatic/IPlatformHttp interfaces from ably-js + * while exposing the UTS MockHttpClient interface (PendingConnection + PendingRequest). + * + * See: specification/uts/rest/unit/helpers/mock_http.md + */ + +interface ConnectionResult { + success: boolean; + error?: { code: string; statusCode: number; message: string }; +} + +interface RequestResult { + error: { message: string; code: number; statusCode: number } | null; + body: string | null; + headers: Record; + unpacked: boolean; + statusCode: number; +} + +/** + * Represents a pending TCP connection attempt. + * Test code calls one of the respond_with_* methods to control the outcome. + */ +class PendingConnection { + host: string; + port: number; + tls: boolean; + timestamp: number; + _resolve: ((value: ConnectionResult) => void) | null; + _promise: Promise; + + constructor(host: string, port: number, tls: boolean) { + this.host = host; + this.port = port; + this.tls = tls; + this.timestamp = Date.now(); + this._resolve = null; + this._promise = new Promise((resolve) => { + this._resolve = resolve; + }); + } + + /** Connection succeeds — HTTP requests can proceed */ + respond_with_success(): void { + this._resolve!({ success: true }); + } + + /** Connection refused at network level */ + respond_with_refused(): void { + this._resolve!({ success: false, error: { code: 'ECONNREFUSED', statusCode: 500, message: 'Connection refused' } }); + } + + /** Connection times out (unresponsive) */ + respond_with_timeout(): void { + this._resolve!({ success: false, error: { code: 'ETIMEDOUT', statusCode: 500, message: 'Connection timed out' } }); + } + + /** DNS resolution fails */ + respond_with_dns_error(): void { + this._resolve!({ success: false, error: { code: 'ENOTFOUND', statusCode: 500, message: 'DNS resolution failed' } }); + } +} + +/** + * Represents a pending HTTP request (after connection succeeded). + * Test code calls respond_with() to provide the response. + */ +class PendingRequest { + method: string; + url: URL; + path: string; + headers: Record; + body: any; + params: Record | null; + timestamp: number; + _resolve: ((value: RequestResult) => void) | null; + _promise: Promise; + + constructor( + method: string, + uri: string, + headers?: Record, + body?: any, + params?: Record | null, + ) { + this.method = method; + this.url = new URL(uri); + this.path = this.url.pathname; + this.headers = headers || {}; + this.body = body; + this.params = params || null; + this.timestamp = Date.now(); + this._resolve = null; + this._promise = new Promise((resolve) => { + this._resolve = resolve; + }); + } + + /** Respond with an HTTP response */ + respond_with(status: number, body: any, headers?: Record): void { + const responseHeaders = headers || {}; + const isError = status >= 400; + let error: RequestResult['error'] = null; + + if (isError) { + // Extract error info from body if present + const errBody = typeof body === 'object' && body !== null && body.error ? body.error : null; + error = { + message: errBody ? errBody.message : `HTTP ${status}`, + code: errBody ? errBody.code : status * 100, + statusCode: errBody ? errBody.statusCode || status : status, + }; + } + + this._resolve!({ + error: error, + body: typeof body === 'string' ? body : JSON.stringify(body), + headers: responseHeaders, + unpacked: false, + statusCode: status, + }); + } + + /** Request times out after connection established */ + respond_with_timeout(): void { + this._resolve!({ + error: { code: 408, statusCode: 408, message: 'Request timed out' } as any, + body: null, + headers: {}, + unpacked: false, + statusCode: 408, + }); + } +} + +interface MockHttpClientOptions { + onConnectionAttempt?: (conn: PendingConnection) => void; + onRequest?: (req: PendingRequest) => void; +} + +type ConnectionWaiter = (conn: PendingConnection) => void; +type RequestWaiter = (req: PendingRequest) => void; + +/** + * MockHttpClient — the main mock class. + * + * Usage (handler pattern): + * const mock = new MockHttpClient({ + * onConnectionAttempt: (conn) => conn.respond_with_success(), + * onRequest: (req) => req.respond_with(200, { time: 123 }) + * }); + * + * Usage (await pattern): + * const mock = new MockHttpClient(); + * // ... start client operation ... + * const conn = await mock.await_connection_attempt(); + * conn.respond_with_success(); + * const req = await mock.await_request(); + * req.respond_with(200, { time: 123 }); + */ +class MockHttpClient { + onConnectionAttempt: ((conn: PendingConnection) => void) | null; + onRequest: ((req: PendingRequest) => void) | null; + captured_requests: PendingRequest[]; + private _connectionWaiters: ConnectionWaiter[]; + private _requestWaiters: RequestWaiter[]; + + constructor(options?: MockHttpClientOptions) { + options = options || {}; + this.onConnectionAttempt = options.onConnectionAttempt || null; + this.onRequest = options.onRequest || null; + this.captured_requests = []; + this._connectionWaiters = []; + this._requestWaiters = []; + } + + /** Wait for the next connection attempt */ + await_connection_attempt(timeout?: number): Promise { + return new Promise((resolve, reject) => { + const timer = timeout + ? setTimeout(() => reject(new Error('Timeout waiting for connection attempt')), timeout) + : null; + this._connectionWaiters.push((conn) => { + if (timer) clearTimeout(timer); + resolve(conn); + }); + }); + } + + /** Wait for the next HTTP request (after connection succeeds) */ + await_request(timeout?: number): Promise { + return new Promise((resolve, reject) => { + const timer = timeout ? setTimeout(() => reject(new Error('Timeout waiting for request')), timeout) : null; + this._requestWaiters.push((req) => { + if (timer) clearTimeout(timer); + resolve(req); + }); + }); + } + + /** Clear all state */ + reset(): void { + this.captured_requests = []; + this._connectionWaiters = []; + this._requestWaiters = []; + } + + /** + * Returns an object conforming to IPlatformHttpStatic that can be assigned + * to Platform.Http. + */ + asPlatformHttp(): any { + const mock = this; + + class MockPlatformHttp { + static methods = ['get', 'delete', 'post', 'put', 'patch']; + static methodsWithBody = ['post', 'put', 'patch']; + static methodsWithoutBody = ['get', 'delete']; + + supportsAuthHeaders: boolean; + supportsLinkHeaders: boolean; + + constructor() { + this.supportsAuthHeaders = true; + this.supportsLinkHeaders = true; + } + + async doUri( + method: string, + uri: string, + headers: Record, + body: any, + params: Record, + ): Promise { + // Phase 1: Connection attempt + let parsedUrl: URL; + try { + // Append params to URL (mirrors real HTTP behavior) + let fullUri = uri; + if (params && typeof params === 'object') { + const qs = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)) + .join('&'); + if (qs) { + fullUri += (uri.includes('?') ? '&' : '?') + qs; + } + } + parsedUrl = new URL(fullUri); + } catch (e) { + return { + error: { message: 'Invalid URI: ' + uri, statusCode: 400, code: 40000 }, + body: null, + headers: {}, + unpacked: false, + statusCode: 400, + }; + } + + const host = parsedUrl.hostname; + const port = parseInt(parsedUrl.port) || (parsedUrl.protocol === 'https:' ? 443 : 80); + const tls = parsedUrl.protocol === 'https:'; + + const conn = new PendingConnection(host, port, tls); + + // Notify handler or waiter + if (mock.onConnectionAttempt) { + mock.onConnectionAttempt(conn); + } else if (mock._connectionWaiters.length > 0) { + mock._connectionWaiters.shift()!(conn); + } else { + // Auto-succeed if no handler + conn.respond_with_success(); + } + + const connResult = await conn._promise; + + if (!connResult.success) { + return { error: connResult.error as any, body: null, headers: {}, unpacked: false, statusCode: 0 }; + } + + // Phase 2: HTTP request (use parsedUrl which includes params) + const req = new PendingRequest(method, parsedUrl.href, headers, body, params); + mock.captured_requests.push(req); + + // Notify handler or waiter + if (mock.onRequest) { + mock.onRequest(req); + } else if (mock._requestWaiters.length > 0) { + mock._requestWaiters.shift()!(req); + } else { + // Default: 404 + req.respond_with(404, { error: { message: 'No handler configured', code: 40400 } }); + } + + return req._promise; + } + + async checkConnectivity(): Promise { + // Perform the connectivity check via doUri (same as real implementation) + const url = 'https://internet-up.ably-realtime.com/is-the-internet-up.txt'; + const { error, body } = await this.doUri('get', url, {}, null, null as any); + return !error && (body as string)?.toString().trim() === 'yes'; + } + + shouldFallback(error: any): boolean { + if (!error) return false; + const code = error.code; + const statusCode = error.statusCode; + if ( + code === 'ECONNREFUSED' || + code === 'ENETUNREACH' || + code === 'EHOSTUNREACH' || + code === 'ETIMEDOUT' || + code === 'ECONNRESET' || + code === 'ENOTFOUND' + ) { + return true; + } + return statusCode >= 500 && statusCode <= 504; + } + } + + return MockPlatformHttp; + } +} + +export { MockHttpClient, PendingConnection, PendingRequest }; diff --git a/test/uts/realtime/time.test.ts b/test/uts/realtime/time.test.ts new file mode 100644 index 0000000000..5f9f6c6793 --- /dev/null +++ b/test/uts/realtime/time.test.ts @@ -0,0 +1,161 @@ +/** + * UTS: Realtime Time API Tests + * + * Spec points: RTC6, RTC6a + * Source: specification/uts/realtime/unit/client/realtime_time.md + * + * RTC6a: RealtimeClient#time proxies to RestClient#time. + * These are the same tests as uts/rest/time but using a Realtime client + * with autoConnect: false to avoid WebSocket connection. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/realtime/time', function () { + let mock; + + afterEach(function () { + restoreAll(); + }); + + /** + * RTC6a - time() returns server time (proxied from REST) + */ + it('RTC6a - time() returns server time', async function () { + const captured: any[] = []; + const serverTimeMs = 1704067200000; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [serverTimeMs]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + const result = await client.time(); + + expect(result).to.be.a('number'); + expect(result).to.equal(serverTimeMs); + + expect(captured).to.have.length(1); + expect(captured[0].method.toUpperCase()).to.equal('GET'); + expect(captured[0].path).to.equal('/time'); + }); + + /** + * RTC6a - time() request format (proxied from REST) + */ + it('RTC6a - time() request format', async function () { + const captured: any[] = []; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + await client.time(); + + expect(captured).to.have.length(1); + const request = captured[0]; + + expect(request.method.toUpperCase()).to.equal('GET'); + expect(request.path).to.equal('/time'); + expect(request.headers).to.have.property('X-Ably-Version'); + expect(request.headers).to.have.property('Ably-Agent'); + expect(request.headers['X-Ably-Version']).to.match(/[0-9.]+/); + expect(request.headers['Ably-Agent']).to.match(/ably-js\/[0-9]+\.[0-9]+\.[0-9]+/); + }); + + /** + * RTC6a - time() does not require authentication (proxied from REST) + */ + it('RTC6a - time() does not require authentication', async function () { + const captured: any[] = []; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + const result = await client.time(); + + expect(result).to.be.a('number'); + expect(captured).to.have.length(1); + expect(captured[0].headers).to.not.have.property('Authorization'); + expect(captured[0].headers).to.not.have.property('authorization'); + }); + + /** + * RTC6a - time() works without TLS (proxied from REST) + */ + it('RTC6a - time() works without TLS', async function () { + const captured: any[] = []; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ + key: 'app.key:secret', + tls: false, + useTokenAuth: true, + autoConnect: false, + }); + const result = await client.time(); + + expect(result).to.be.a('number'); + expect(captured).to.have.length(1); + expect(captured[0].url.protocol).to.equal('http:'); + expect(captured[0].headers).to.not.have.property('Authorization'); + expect(captured[0].headers).to.not.have.property('authorization'); + }); + + /** + * RTC6a - time() error handling (proxied from REST) + */ + it('RTC6a - time() error handling', async function () { + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + error: { + message: 'Internal server error', + code: 50000, + statusCode: 500, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + expect(error.code).to.equal(50000); + } + }); +}); diff --git a/test/uts/rest/auth/auth_callback.test.ts b/test/uts/rest/auth/auth_callback.test.ts new file mode 100644 index 0000000000..f726e096af --- /dev/null +++ b/test/uts/rest/auth/auth_callback.test.ts @@ -0,0 +1,379 @@ +/** + * UTS: Auth Callback Tests + * + * Spec points: RSA8c, RSA8d + * Source: specification/uts/rest/unit/auth/auth_callback.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function simpleMock(captured: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + req.respond_with(200, []); + }, + }); +} + +function authUrlMock(captured: any, tokenValue?: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + if (req.url.host === 'auth.example.com') { + req.respond_with(200, tokenValue || 'authurl-token', { 'content-type': 'text/plain' }); + } else { + req.respond_with(200, []); + } + }, + }); +} + +describe('uts/rest/auth/auth_callback', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA8d - authCallback invoked for authentication + */ + it('RSA8d - authCallback invoked for authentication', async function () { + const captured: any[] = []; + let callbackInvoked = false; + + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callbackInvoked = true; + callback(null, 'callback-token'); + }, + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(callbackInvoked).to.be.true; + expect(captured).to.have.length(1); + const expectedAuth = 'Bearer ' + Buffer.from('callback-token').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA8d - authCallback returning JWT string + */ + it('RSA8d - authCallback returning JWT string', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload'; + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, jwt); + }, + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(captured).to.have.length(1); + const expectedAuth = 'Bearer ' + Buffer.from(jwt).toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA8d - authCallback returning TokenRequest + */ + it('RSA8d - authCallback returning TokenRequest', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'exchanged-token', + expires: Date.now() + 3600000, + issued: Date.now(), + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, { + keyName: 'app.key', + ttl: 3600000, + timestamp: Date.now(), + nonce: 'unique-nonce', + mac: 'computed-mac', + } as any); + }, + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(captured.length).to.be.at.least(2); + + // First request was POST to /keys/.../requestToken + expect(captured[0].method.toUpperCase()).to.equal('POST'); + expect(captured[0].path).to.match(/\/keys\/.*\/requestToken/); + + // Second request used the exchanged token + const apiReq = captured[captured.length - 1]; + const expectedAuth = 'Bearer ' + Buffer.from('exchanged-token').toString('base64'); + expect(apiReq.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA8d - authCallback receives TokenParams + */ + it('RSA8d - authCallback receives TokenParams', async function () { + let receivedParams: any = null; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + receivedParams = params; + callback(null, 'test-token'); + }, + } as any); + await client.auth.authorize({ + clientId: 'requested-client-id', + ttl: 7200000, + capability: { channel1: ['publish'] }, + } as any); + + expect(receivedParams).to.not.be.null; + expect(receivedParams.clientId).to.equal('requested-client-id'); + expect(receivedParams.ttl).to.equal(7200000); + // ably-js serializes capability as a JSON string + const cap = + typeof receivedParams.capability === 'string' ? JSON.parse(receivedParams.capability) : receivedParams.capability; + expect(cap).to.deep.equal({ channel1: ['publish'] }); + }); + + /** + * RSA8c - authUrl invoked for authentication (GET) + */ + it('RSA8c - authUrl invoked for authentication (GET)', async function () { + const captured: any[] = []; + installMockHttp(authUrlMock(captured)); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(captured.length).to.be.at.least(2); + + // First request was to authUrl + const authReq = captured[0]; + expect(authReq.url.host).to.equal('auth.example.com'); + expect(authReq.path).to.equal('/token'); + expect(authReq.method.toUpperCase()).to.equal('GET'); + + // Second request used the token + const apiReq = captured[captured.length - 1]; + const expectedAuth = 'Bearer ' + Buffer.from('authurl-token').toString('base64'); + expect(apiReq.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA8c - authUrl with POST method + */ + it('RSA8c - authUrl with POST method', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + if (req.url.host === 'auth.example.com') { + req.respond_with(200, 'authurl-token', { 'content-type': 'text/plain' }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + authMethod: 'POST', + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + const authReq = captured[0]; + expect(authReq.method.toUpperCase()).to.equal('POST'); + }); + + /** + * RSA8c - authUrl with custom headers + */ + it('RSA8c - authUrl with custom headers', async function () { + const captured: any[] = []; + installMockHttp(authUrlMock(captured)); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + authHeaders: { + 'X-Custom-Header': 'custom-value', + 'X-API-Key': 'my-api-key', + }, + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + const authReq = captured[0]; + expect(authReq.headers['X-Custom-Header']).to.equal('custom-value'); + expect(authReq.headers['X-API-Key']).to.equal('my-api-key'); + }); + + /** + * RSA8c - authUrl with query params + */ + it('RSA8c - authUrl with query params', async function () { + const captured: any[] = []; + installMockHttp(authUrlMock(captured)); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + authParams: { + client_id: 'my-client', + scope: 'publish:*', + }, + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + const authReq = captured[0]; + expect(authReq.url.searchParams.get('client_id')).to.equal('my-client'); + expect(authReq.url.searchParams.get('scope')).to.equal('publish:*'); + }); + + /** + * RSA8c - authUrl returning JWT string + */ + it('RSA8c - authUrl returning JWT string', async function () { + const captured: any[] = []; + const jwt = 'eyJhbGciOiJIUzI1NiJ9.jwt-body.signature'; + installMockHttp(authUrlMock(captured, jwt)); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/jwt', + } as any); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + const apiReq = captured[captured.length - 1]; + const expectedAuth = 'Bearer ' + Buffer.from(jwt).toString('base64'); + expect(apiReq.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA8d - authCallback error propagated + */ + it('RSA8d - authCallback error propagated', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(new Error('Authentication server unavailable')); + }, + } as any); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + // UTS spec: error.message CONTAINS "Authentication server unavailable" + // ably-js wraps the original error — check the message is preserved somewhere + const errorStr = String(error.message || error); + expect(errorStr).to.include('Authentication server unavailable'); + } + + // No API requests should have been made + expect(captured).to.have.length(0); + }); + + /** + * RSA8c - authUrl error propagated + */ + it('RSA8c - authUrl error propagated', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + if (req.url.host === 'auth.example.com') { + req.respond_with(500, { error: 'Internal server error' }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + } as any); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error: any) { + // UTS spec: error.statusCode == 500 OR error.message CONTAINS "auth" + const hasExpectedStatus = error.statusCode === 500 || error.statusCode === 401; + const hasAuthMessage = String(error.message || '') + .toLowerCase() + .includes('auth'); + expect(hasExpectedStatus || hasAuthMessage).to.be.true; + } + + // Only authUrl request was made, not the API request + expect(captured).to.have.length(1); + expect(captured[0].url.host).to.equal('auth.example.com'); + }); +}); diff --git a/test/uts/rest/auth/auth_scheme.test.ts b/test/uts/rest/auth/auth_scheme.test.ts new file mode 100644 index 0000000000..1bce9dd03a --- /dev/null +++ b/test/uts/rest/auth/auth_scheme.test.ts @@ -0,0 +1,311 @@ +/** + * UTS: Auth Scheme Selection Tests + * + * Spec points: RSA1, RSA2, RSA3, RSA4, RSA4a2, RSA11, RSC1b, RSC18 + * Source: specification/uts/rest/unit/auth/auth_scheme.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +/** Standard mock that auto-succeeds and returns 200 */ +function simpleMock(captured: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); +} + +/** Mock that routes requestToken vs API requests */ +function tokenRoutingMock(captured: any, tokenValue?: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: tokenValue || 'obtained-token', + expires: Date.now() + 3600000, + issued: Date.now(), + capability: JSON.stringify({ '*': ['*'] }), + }); + } else { + req.respond_with(200, []); + } + }, + }); +} + +describe('uts/rest/auth/auth_scheme', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA4 - Basic auth with API key only + */ + it('RSA4 - Basic auth with API key only', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(captured).to.have.length(1); + const expectedAuth = 'Basic ' + Buffer.from('appId.keyId:keySecret').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA3 - Token auth with explicit token string + */ + it('RSA3 - Token auth with explicit token string', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ token: 'explicit-token-string' }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(captured).to.have.length(1); + const expectedAuth = 'Bearer ' + Buffer.from('explicit-token-string').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA3 - Token auth with TokenDetails + */ + it('RSA3 - Token auth with TokenDetails', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + tokenDetails: { + token: 'token-from-details', + expires: Date.now() + 3600000, + } as any, + }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(captured).to.have.length(1); + const expectedAuth = 'Bearer ' + Buffer.from('token-from-details').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA4 - useTokenAuth forces token auth + */ + it('RSA4 - useTokenAuth forces token auth', async function () { + const captured: any[] = []; + installMockHttp(tokenRoutingMock(captured, 'obtained-token')); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useTokenAuth: true, + }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + // API request should use Bearer, not Basic + const apiRequest = captured[captured.length - 1]; + const expectedAuth = 'Bearer ' + Buffer.from('obtained-token').toString('base64'); + expect(apiRequest.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA4 - authCallback triggers token auth + */ + it('RSA4 - authCallback triggers token auth', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callback(null, 'callback-token'); + }, + }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(captured).to.have.length(1); + const expectedAuth = 'Bearer ' + Buffer.from('callback-token').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA4 - authUrl triggers token auth + */ + it('RSA4 - authUrl triggers token auth', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + if (req.url.host === 'auth.example.com') { + req.respond_with(200, 'authurl-token', { 'content-type': 'text/plain' }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(captured.length).to.be.at.least(2); + const apiRequest = captured[captured.length - 1]; + const expectedAuth = 'Bearer ' + Buffer.from('authurl-token').toString('base64'); + expect(apiRequest.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSC1b - Error when no auth method available + */ + it('RSC1b - Error when no auth method available', function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + try { + new Ably.Rest({}); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.code).to.equal(40106); + } + + expect(captured).to.have.length(0); + }); + + /** + * RSA4a2 - Error when token expired and no renewal method + * + * Per RSA4a2: if the server responds with a token error (40142) and + * there's no way to renew, the library should error with 40171. + * Note: RSA4b1 (local expiry detection) is optional. + */ + it('RSA4a2 - Error when token expired and no renewal method', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + // Server rejects expired token + req.respond_with(401, { + error: { message: 'Token expired', code: 40142, statusCode: 401 }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + tokenDetails: { + token: 'expired-token', + expires: Date.now() - 1000, + } as any, + }); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error: any) { + expect(error.code).to.equal(40171); + } + }); + + /** + * RSA1 - Auth method priority (authCallback over key) + */ + it('RSA1 - Auth method priority (authCallback over key)', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + authCallback: function (params, callback) { + callback(null, 'callback-token'); + }, + }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + const request = captured[0]; + const expectedAuth = 'Bearer ' + Buffer.from('callback-token').toString('base64'); + expect(request.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA2, RSA11 - Basic auth header format + */ + it('RSA2, RSA11 - Basic auth header format', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ key: 'app123.key456:secretXYZ' }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + const request = captured[0]; + const expected = 'Basic ' + Buffer.from('app123.key456:secretXYZ').toString('base64'); + expect(request.headers.authorization).to.equal(expected); + }); + + /** + * RSC18 - Token auth allowed over non-TLS + */ + it('RSC18 - Token auth allowed over non-TLS', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + token: 'explicit-token', + tls: false, + }); + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + const request = captured[0]; + const expectedAuth = 'Bearer ' + Buffer.from('explicit-token').toString('base64'); + expect(request.headers.authorization).to.equal(expectedAuth); + expect(request.url.protocol).to.equal('http:'); + }); +}); diff --git a/test/uts/rest/auth/authorize.test.ts b/test/uts/rest/auth/authorize.test.ts new file mode 100644 index 0000000000..1898c1e3be --- /dev/null +++ b/test/uts/rest/auth/authorize.test.ts @@ -0,0 +1,348 @@ +/** + * UTS: Auth.authorize() Tests + * + * Spec points: RSA10, RSA10a, RSA10b, RSA10g, RSA10h, RSA10j, RSA10k, RSA10l + * Source: specification/uts/rest/unit/auth/authorize.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function tokenRoutingMock(captured: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + if (captured) captured.push(req); + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'obtained-token', + expires: Date.now() + 3600000, + issued: Date.now(), + keyName: 'appId.keyId', + }); + } else { + req.respond_with(200, []); + } + }, + }); +} + +describe('uts/rest/auth/authorize', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA10a - authorize() obtains token with defaults + */ + it('RSA10a - authorize() obtains token', async function () { + const captured: any[] = []; + installMockHttp(tokenRoutingMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + const tokenDetails = await client.auth.authorize(); + + expect(tokenDetails).to.be.an('object'); + expect(tokenDetails.token).to.equal('obtained-token'); + + // Verify token is now used for requests + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const apiReq = captured[captured.length - 1]; + const expectedAuth = 'Bearer ' + Buffer.from('obtained-token').toString('base64'); + expect(apiReq.headers.authorization).to.equal(expectedAuth); + }); + + /** + * RSA10b - authorize() with explicit tokenParams overrides defaults + */ + it('RSA10b - tokenParams override defaults', async function () { + let callbackParams: any = null; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackParams = params; + callback(null, 'callback-token'); + }, + clientId: 'default-client', + }); + + await client.auth.authorize({ + clientId: 'override-client', + ttl: 7200000, + }); + + expect(callbackParams).to.not.be.null; + expect(callbackParams!.clientId).to.equal('override-client'); + expect(callbackParams!.ttl).to.equal(7200000); + }); + + /** + * RSA10g - authorize() updates auth.tokenDetails + */ + it('RSA10g - authorize() updates tokenDetails', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'new-token', + expires: Date.now() + 3600000, + issued: Date.now(), + keyName: 'appId.keyId', + clientId: 'token-client', + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + // Before authorize + expect(client.auth.tokenDetails).to.satisfy((v: any) => v === null || v === undefined); + + const result = await client.auth.authorize(); + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('new-token'); + expect(result.token).to.equal('new-token'); + }); + + /** + * RSA10h - authorize() with new authCallback replaces old + */ + it('RSA10h - authOptions replace stored options', async function () { + let originalCalled = false; + let newCalled = false; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + originalCalled = true; + callback(null, 'original-token'); + }, + }); + + await client.auth.authorize(null, { + authCallback: function (params, callback) { + newCalled = true; + callback(null, 'new-token'); + }, + }); + + expect(originalCalled).to.be.false; + expect(newCalled).to.be.true; + }); + + /** + * RSA10j - authorize() when already authorized gets new token + */ + it('RSA10j - authorize() when already authorized', async function () { + let tokenCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + tokenCount++; + callback(null, { + token: 'token-' + tokenCount, + expires: Date.now() + 3600000, + issued: Date.now(), + } as any); + }, + }); + + const result1 = await client.auth.authorize(); + const result2 = await client.auth.authorize(); + + expect(result1.token).to.equal('token-1'); + expect(result2.token).to.equal('token-2'); + expect(client.auth.tokenDetails!.token).to.equal('token-2'); + }); + + /** + * RSA10k - authorize() with queryTime queries server time + */ + it('RSA10k - queryTime queries server', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + if (req.path === '/time') { + req.respond_with(200, [Date.now()]); + } else if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'time-synced-token', + expires: Date.now() + 3600000, + issued: Date.now(), + keyName: 'appId.keyId', + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + // Must include key in authOptions since authorize() replaces stored options + await client.auth.authorize(null, { key: 'appId.keyId:keySecret', queryTime: true }); + + // Should have made a request to /time + const timeReq = captured.find((r) => r.path === '/time'); + expect(timeReq).to.not.be.undefined; + }); + + /** + * RSA10l - authorize() error handling + */ + it('RSA10l - authorize() propagates errors', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + error: { + code: 40100, + statusCode: 401, + message: 'Unauthorized', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'invalid.key:secret' }); + + try { + await client.auth.authorize(); + expect.fail('Expected authorize to throw'); + } catch (error: any) { + expect(error.code).to.equal(40100); + expect(error.statusCode).to.equal(401); + } + }); + + /** + * RSA10e - authorize() saves tokenParams for reuse + * + * tokenParams provided to authorize() are saved and reused on subsequent + * token requests (e.g. when the token expires and is re-acquired). + */ + it('RSA10e - tokenParams saved for reuse', async function () { + const callbackInvocations: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackInvocations.push({ ...params }); + callback(null, { + token: 'token-' + callbackInvocations.length, + expires: Date.now() + 3600000, + issued: Date.now(), + } as any); + }, + }); + + // First authorize with custom tokenParams + await client.auth.authorize({ + clientId: 'saved-client', + ttl: 3600000, + }); + + // Second authorize without explicit tokenParams — should reuse saved + await client.auth.authorize(); + + expect(callbackInvocations).to.have.length(2); + // Second callback should have received the saved params + expect(callbackInvocations[1].clientId).to.equal('saved-client'); + expect(callbackInvocations[1].ttl).to.equal(3600000); + }); + + /** + * RSA10i - authorize() preserves key from constructor + * + * The API key from ClientOptions is preserved even when authOptions + * are provided to authorize(). + */ + it('RSA10i - key preserved after authorize with authOptions', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'token-via-key', + expires: Date.now() + 3600000, + issued: Date.now(), + keyName: 'appId.keyId', + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + // Authorize with queryTime option (but same key) + await client.auth.authorize(null, { key: 'appId.keyId:keySecret', queryTime: false }); + + // Key should still work — make a second authorize + const result = await client.auth.authorize(); + expect(result).to.be.an('object'); + expect(result.token).to.be.a('string'); + }); + + /** + * RSA10a - authorize() with incompatible key throws 40102 + */ + it('RSA10a - incompatible key in authOptions throws 40102', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + try { + await client.auth.authorize(null, { key: 'different.key:secret' }); + expect.fail('Expected authorize to throw'); + } catch (error: any) { + expect(error.code).to.equal(40102); + } + }); +}); diff --git a/test/uts/rest/auth/client_id.test.ts b/test/uts/rest/auth/client_id.test.ts new file mode 100644 index 0000000000..6e597aae07 --- /dev/null +++ b/test/uts/rest/auth/client_id.test.ts @@ -0,0 +1,407 @@ +/** + * UTS: Client ID Tests + * + * Spec points: RSA7, RSA7a, RSA7b, RSA7c, RSA12, RSA12a, RSA12b, RSA15, RSA15a, RSA15b, RSA15c + * Source: specification/uts/rest/unit/auth/client_id.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function simpleMock(captured: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + req.respond_with(200, []); + }, + }); +} + +describe('uts/rest/auth/client_id', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA7a - clientId from ClientOptions + */ + it('RSA7a - clientId from ClientOptions', function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + clientId: 'my-client-id', + }); + + expect(client.auth.clientId).to.equal('my-client-id'); + }); + + /** + * RSA7b - clientId from TokenDetails + * + * Per spec, clientId from TokenDetails passed at construction should be + * accessible via auth.clientId. + */ + it('RSA7b - clientId from TokenDetails', function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + tokenDetails: { + token: 'token-with-clientId', + expires: Date.now() + 3600000, + clientId: 'token-client-id', + } as any, + } as any); + + expect(client.auth.clientId).to.equal('token-client-id'); + }); + + /** + * RSA7b - clientId from authCallback TokenDetails + * + * Per spec, clientId from TokenDetails returned by authCallback should + * update auth.clientId after the first auth request. + */ + it('RSA7b - clientId from authCallback TokenDetails', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, { + token: 'callback-token', + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'callback-client-id', + } as any); + }, + } as any); + + // Trigger auth by making a request + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(client.auth.clientId).to.equal('callback-client-id'); + }); + + /** + * RSA7c - clientId null when unidentified + */ + it('RSA7c - clientId null when unidentified', function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + expect(client.auth.clientId).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA7c - clientId null with unidentified token + */ + it('RSA7c - clientId null with unidentified token', function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + tokenDetails: { + token: 'token-without-clientId', + expires: Date.now() + 3600000, + } as any, + } as any); + + expect(client.auth.clientId).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA12a - clientId passed to authCallback in TokenParams + */ + it('RSA12a - clientId passed to authCallback in TokenParams', async function () { + let receivedParams: any = null; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + receivedParams = params; + callback(null, 'test-token'); + }, + clientId: 'library-client-id', + } as any); + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(receivedParams).to.not.be.null; + expect(receivedParams.clientId).to.equal('library-client-id'); + }); + + /** + * RSA12b - clientId sent to authUrl as query param + */ + it('RSA12b - clientId sent to authUrl', async function () { + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + captured.push(req); + if (req.url.host === 'auth.example.com') { + req.respond_with(200, 'url-token', { 'content-type': 'text/plain' }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + clientId: 'url-client-id', + } as any); + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + const authReq = captured[0]; + expect(authReq.url.host).to.equal('auth.example.com'); + // clientId should be in query params (GET is default) + expect(authReq.url.searchParams.get('clientId')).to.equal('url-client-id'); + }); + + /** + * RSA7 - clientId updated after authorize() + * + * Per spec, auth.clientId should be updated when authorize() returns + * a new token with a different clientId. + */ + it('RSA7 - clientId updated after authorize()', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + let tokenCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => req.respond_with(200, []), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + tokenCount++; + callback(null, { + token: 'token-' + tokenCount, + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'client-' + tokenCount, + } as any); + }, + } as any); + + // First auth + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + expect(client.auth.clientId).to.equal('client-1'); + + // Second auth with explicit authorize + await client.auth.authorize(); + expect(client.auth.clientId).to.equal('client-2'); + }); + + /** + * RSA12 - Wildcard clientId + * + * Per spec, wildcard '*' clientId in TokenDetails should be preserved + * and accessible via auth.clientId. + */ + it('RSA12 - Wildcard clientId', function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + tokenDetails: { + token: 'wildcard-token', + expires: Date.now() + 3600000, + clientId: '*', + } as any, + } as any); + + expect(client.auth.clientId).to.equal('*'); + }); + + /** + * RSA7 - Consistency case 3: explicit clientId in options, null in token + * + * When ClientOptions.clientId is set but the token has no clientId, + * the client should keep the explicit clientId from options. + */ + it('RSA7 - case 3: explicit clientId kept when token has none', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + clientId: 'explicit-client', + authCallback: function (params: any, callback: any) { + callback(null, { + token: 'token-no-clientId', + expires: Date.now() + 3600000, + issued: Date.now(), + // no clientId in token + } as any); + }, + } as any); + + // Force auth + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(client.auth.clientId).to.equal('explicit-client'); + }); + + /** + * RSA7 - Consistency case 5: no clientId in options, clientId in token + * + * When ClientOptions.clientId is not set but the token has a clientId, + * the client should inherit the clientId from the token. + * + * DEVIATION: ably-js does not derive auth.clientId from TokenDetails + * for REST clients — see deviations.md (RSA7b). This test documents + * the expected behavior even though it currently fails. + */ + it('RSA7 - case 5: clientId inherited from token', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + // no clientId in options + authCallback: function (params: any, callback: any) { + callback(null, { + token: 'token-with-clientId', + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'token-client', + } as any); + }, + } as any); + + // Force auth + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + // Per spec, should inherit clientId from token + expect(client.auth.clientId).to.equal('token-client'); + }); + + /** + * RSA15a - Matching clientId succeeds + */ + it('RSA15a - Matching clientId succeeds', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + clientId: 'my-client', + tokenDetails: { + token: 'matching-token', + expires: Date.now() + 3600000, + clientId: 'my-client', + } as any, + } as any); + + // Should not throw when using the token + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(client.auth.clientId).to.equal('my-client'); + }); + + /** + * RSA15a - Mismatched clientId error (40102) + * + * Per spec, if ClientOptions.clientId and TokenDetails.clientId are both + * non-wildcard and don't match, an error with code 40102 must be raised. + */ + it('RSA15a - Mismatched clientId error (40102)', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + clientId: 'client-a', + tokenDetails: { + token: 'mismatched-token', + expires: Date.now() + 3600000, + clientId: 'client-b', + } as any, + } as any); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error: any) { + expect(error.code).to.equal(40102); + } + }); + + /** + * RSA15b - Wildcard token clientId permits any ClientOptions clientId + */ + it('RSA15b - Wildcard token clientId permits any ClientOptions clientId', async function () { + const captured: any[] = []; + installMockHttp(simpleMock(captured)); + + const client = new Ably.Rest({ + clientId: 'any-client', + tokenDetails: { + token: 'wildcard-token', + expires: Date.now() + 3600000, + clientId: '*', + } as any, + } as any); + + // Should not throw — wildcard allows any clientId + try { + await client.stats({} as any); + } catch (e) { + /* response parse errors ok */ + } + + expect(client.auth.clientId).to.equal('any-client'); + }); +}); diff --git a/test/uts/rest/auth/revoke_tokens.test.ts b/test/uts/rest/auth/revoke_tokens.test.ts new file mode 100644 index 0000000000..53c11cd528 --- /dev/null +++ b/test/uts/rest/auth/revoke_tokens.test.ts @@ -0,0 +1,400 @@ +/** + * UTS: Revoke Tokens Tests + * + * Spec points: RSA17, RSA17b, RSA17c, RSA17d, RSA17e, RSA17f, RSA17g, BAR2, TRS2, TRF2 + * Source: specification/uts/rest/unit/auth/revoke_tokens.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function revokeMock(captured: any, responseBody?: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + if (captured) captured.push(req); + req.respond_with( + 200, + responseBody || { + successCount: 1, + failureCount: 0, + results: [{ target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }], + }, + ); + }, + }); +} + +describe('uts/rest/auth/revoke_tokens', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA17g - POST to /keys/{keyName}/revokeTokens + */ + it('RSA17g - sends POST to correct path', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + + expect(captured).to.have.length(1); + expect(captured[0].method.toUpperCase()).to.equal('POST'); + expect(captured[0].path).to.equal('/keys/appId.keyName/revokeTokens'); + }); + + /** + * RSA17b - Single target specifier + */ + it('RSA17b - single specifier sent as targets array', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + + const body = JSON.parse(captured[0].body); + expect(body.targets).to.deep.equal(['clientId:alice']); + }); + + /** + * RSA17b - Multiple specifiers with different types + */ + it('RSA17b - multiple specifiers', async function () { + const captured: any[] = []; + const responseBody = { + successCount: 3, + failureCount: 0, + results: [ + { target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, + { target: 'revocationKey:group-1', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, + { target: 'channel:secret', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, + ], + }; + installMockHttp(revokeMock(captured, responseBody)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([ + { type: 'clientId', value: 'alice' }, + { type: 'revocationKey', value: 'group-1' }, + { type: 'channel', value: 'secret' }, + ]); + + const body = JSON.parse(captured[0].body); + expect(body.targets).to.deep.equal(['clientId:alice', 'revocationKey:group-1', 'channel:secret']); + }); + + /** + * RSA17c / BAR2 - All success result + * + * DEVIATION: UTS spec expects the mock to return a plain array and the + * client to compute successCount/failureCount. ably-js passes through + * the server response as-is (which includes successCount/failureCount/results). + * Mock format matches the actual Ably REST API response format. + */ + it('RSA17c - all success result', async function () { + const responseBody = { + successCount: 2, + failureCount: 0, + results: [ + { target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, + { target: 'clientId:bob', issuedBefore: 1700000000000, appliesAt: 1700000002000 }, + ], + }; + installMockHttp(revokeMock(null, responseBody)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + const result = await client.auth.revokeTokens([ + { type: 'clientId', value: 'alice' }, + { type: 'clientId', value: 'bob' }, + ]); + + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(0); + expect(result.results).to.have.length(2); + }); + + /** + * TRS2 - Success result attributes + */ + it('TRS2 - success result has target, issuedBefore, appliesAt', async function () { + const responseBody = { + successCount: 1, + failureCount: 0, + results: [{ target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }], + }; + installMockHttp(revokeMock(null, responseBody)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + const result = await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + + const success = result.results[0] as any; + expect(success.target).to.equal('clientId:alice'); + expect(success.issuedBefore).to.equal(1700000000000); + expect(success.appliesAt).to.equal(1700000001000); + }); + + /** + * RSA17c_2 - Mixed success and failure result + * + * Per spec: the SDK should normalise the HTTP 400 response containing + * {error, batchResponse} into {successCount, failureCount, results}. + */ + it('RSA17c_2 - mixed result normalised', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, + { target: 'invalidType:abc', error: { code: 40000, statusCode: 400, message: 'Invalid target type' } }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + + const result = await client.auth.revokeTokens([ + { type: 'clientId', value: 'alice' }, + { type: 'invalidType', value: 'abc' }, + ]); + + expect(result.successCount).to.equal(1); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.length(2); + }); + + /** + * RSA17c_3 - All failure result + * + * Per spec: the SDK should normalise the HTTP 400 response into + * {successCount: 0, failureCount: N, results}. + */ + it('RSA17c_3 - all failure normalised', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { target: 'invalidType:foo', error: { code: 40000, statusCode: 400, message: 'Invalid target type' } }, + { target: 'invalidType:bar', error: { code: 40000, statusCode: 400, message: 'Invalid target type' } }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + + const result = await client.auth.revokeTokens([ + { type: 'invalidType', value: 'foo' }, + { type: 'invalidType', value: 'bar' }, + ]); + + expect(result.successCount).to.equal(0); + expect(result.failureCount).to.equal(2); + expect(result.results).to.have.length(2); + }); + + /** + * TRF2_1 - Failure result with target and error details + * + * Per spec: the per-target error details should be accessible in the + * normalised response results. + */ + it('TRF2_1 - failure details in results', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { + target: 'invalidType:abc', + error: { code: 40000, statusCode: 400, message: 'Invalid target type' }, + }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + + const result = await client.auth.revokeTokens([{ type: 'invalidType', value: 'abc' }]); + + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.length(1); + expect(result.results[0].target).to.equal('invalidType:abc'); + expect((result.results[0] as any).error.code).to.equal(40000); + }); + + /** + * RSA17d - Token auth client fails with 40162 + */ + it('RSA17d - token auth client fails with 40162', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ token: 'a.token.string' }); + + try { + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + expect.fail('Expected revokeTokens to throw'); + } catch (error: any) { + expect(error.code).to.equal(40162); + expect(error.statusCode).to.equal(401); + } + + // No HTTP request should have been made + expect(captured).to.have.length(0); + }); + + /** + * RSA17d - useTokenAuth flag also fails with 40162 + */ + it('RSA17d - useTokenAuth flag fails with 40162', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useTokenAuth: true }); + + try { + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + expect.fail('Expected revokeTokens to throw'); + } catch (error: any) { + expect(error.code).to.equal(40162); + expect(error.statusCode).to.equal(401); + } + + expect(captured).to.have.length(0); + }); + + /** + * RSA17e - issuedBefore included when specified + */ + it('RSA17e - issuedBefore included in request body', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }], { issuedBefore: 1699999000000 }); + + const body = JSON.parse(captured[0].body); + expect(body.issuedBefore).to.equal(1699999000000); + }); + + /** + * RSA17e - issuedBefore omitted when not provided + */ + it('RSA17e - issuedBefore omitted when not provided', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + + const body = JSON.parse(captured[0].body); + expect(body).to.not.have.property('issuedBefore'); + }); + + /** + * RSA17f - allowReauthMargin included when true + */ + it('RSA17f - allowReauthMargin included', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }], { allowReauthMargin: true }); + + const body = JSON.parse(captured[0].body); + expect(body.allowReauthMargin).to.equal(true); + }); + + /** + * RSA17f - allowReauthMargin omitted when not provided + */ + it('RSA17f - allowReauthMargin omitted when not provided', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + + const body = JSON.parse(captured[0].body); + expect(body).to.not.have.property('allowReauthMargin'); + }); + + /** + * RSA17f - Both issuedBefore and allowReauthMargin together + */ + it('RSA17f - both options together', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }], { + issuedBefore: 1699999000000, + allowReauthMargin: true, + }); + + const body = JSON.parse(captured[0].body); + expect(body.targets).to.deep.equal(['clientId:alice']); + expect(body.issuedBefore).to.equal(1699999000000); + expect(body.allowReauthMargin).to.equal(true); + }); + + /** + * RSA17 - Server error propagated + */ + it('RSA17 - server error propagated', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + error: { code: 50000, statusCode: 500, message: 'Internal error' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + + try { + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + expect.fail('Expected revokeTokens to throw'); + } catch (error: any) { + expect(error.code).to.equal(50000); + expect(error.statusCode).to.equal(500); + } + }); + + /** + * RSA17 - Request uses Basic authentication + */ + it('RSA17 - request uses Basic auth', async function () { + const captured: any[] = []; + installMockHttp(revokeMock(captured)); + + const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); + await client.auth.revokeTokens([{ type: 'clientId', value: 'alice' }]); + + expect(captured[0].headers.authorization).to.match(/^Basic /); + const expectedAuth = 'Basic ' + Buffer.from('appId.keyName:keySecret').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth); + }); +}); diff --git a/test/uts/rest/auth/token_details.test.ts b/test/uts/rest/auth/token_details.test.ts new file mode 100644 index 0000000000..bb6247d5d7 --- /dev/null +++ b/test/uts/rest/auth/token_details.test.ts @@ -0,0 +1,396 @@ +/** + * UTS: Auth.tokenDetails Tests + * + * Spec points: RSA16, RSA16a, RSA16b, RSA16c, RSA16d + * Source: specification/uts/rest/unit/auth/token_details.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function simpleMock(captured?: any) { + return new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + if (captured) captured.push(req); + req.respond_with(200, []); + }, + }); +} + +describe('uts/rest/auth/token_details', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA16a - tokenDetails reflects token from authCallback + */ + it('RSA16a - tokenDetails from authCallback', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, { + token: 'callback-token-abc', + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'my-client', + } as any); + }, + } as any); + + // Force token acquisition + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('callback-token-abc'); + expect(client.auth.tokenDetails!.clientId).to.equal('my-client'); + expect(client.auth.tokenDetails!.expires).to.be.a('number'); + expect(client.auth.tokenDetails!.issued).to.be.a('number'); + }); + + /** + * RSA16a - tokenDetails reflects token from requestToken (authorize with key) + */ + it('RSA16a - tokenDetails from requestToken', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'requested-token-xyz', + expires: Date.now() + 3600000, + issued: Date.now(), + keyName: 'appId.keyId', + clientId: 'token-client', + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + await client.auth.authorize(); + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('requested-token-xyz'); + expect(client.auth.tokenDetails!.clientId).to.equal('token-client'); + }); + + /** + * RSA16b - tokenDetails created from token string in ClientOptions + */ + it('RSA16b - tokenDetails from token string option', function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ token: 'standalone-token-string' } as any); + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('standalone-token-string'); + // Other fields should be null/undefined since we only had the token string + expect(client.auth.tokenDetails!.expires).to.satisfy((v: any) => v === null || v === undefined); + expect(client.auth.tokenDetails!.issued).to.satisfy((v: any) => v === null || v === undefined); + expect(client.auth.tokenDetails!.clientId).to.satisfy((v: any) => v === null || v === undefined); + expect(client.auth.tokenDetails!.capability).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA16b - tokenDetails created from token string in authCallback + */ + it('RSA16b - tokenDetails from token string authCallback', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, 'just-a-token-string'); + }, + } as any); + + // Force token acquisition + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('just-a-token-string'); + // Other fields should be null/undefined + expect(client.auth.tokenDetails!.expires).to.satisfy((v: any) => v === null || v === undefined); + expect(client.auth.tokenDetails!.issued).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA16c - tokenDetails set on instantiation with tokenDetails option + */ + it('RSA16c - tokenDetails set on instantiation', function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + tokenDetails: { + token: 'initial-token', + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'initial-client', + } as any, + } as any); + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('initial-token'); + expect(client.auth.tokenDetails!.clientId).to.equal('initial-client'); + }); + + /** + * RSA16c - tokenDetails updated after explicit authorize() + */ + it('RSA16c - tokenDetails updated after authorize()', async function () { + let tokenCount = 0; + + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + tokenCount++; + callback(null, { + token: 'token-v' + tokenCount, + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'client-v' + tokenCount, + } as any); + }, + } as any); + + // First authorize + await client.auth.authorize(); + const firstToken = client.auth.tokenDetails; + + // Second authorize + await client.auth.authorize(); + const secondToken = client.auth.tokenDetails; + + expect(firstToken!.token).to.equal('token-v1'); + expect(secondToken!.token).to.equal('token-v2'); + expect(firstToken!.token).to.not.equal(secondToken!.token); + }); + + /** + * RSA16c - tokenDetails updated after library-initiated renewal on 40142 + * + * When a request fails with 40142 (token expired), the library renews + * the token and tokenDetails should reflect the new token. + */ + it('RSA16c - tokenDetails updated after 40142 renewal', async function () { + let requestCount = 0; + let tokenCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + requestCount++; + if (requestCount === 1) { + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + tokenCount++; + callback(null, { + token: 'token-v' + tokenCount, + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'client-v' + tokenCount, + } as any); + }, + } as any); + + // First authorize + await client.auth.authorize(); + const firstToken = client.auth.tokenDetails; + + // Make a request that will fail with 40142, triggering renewal + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const secondToken = client.auth.tokenDetails; + + expect(firstToken!.token).to.equal('token-v1'); + expect(secondToken!.token).to.equal('token-v2'); + }); + + /** + * RSA16d - tokenDetails null after failed renewal attempt + * + * When a token is invalidated and renewal fails, tokenDetails + * should reflect the failure state. + */ + it('RSA16d - tokenDetails after failed renewal', async function () { + this.timeout(5000); + let callbackCount = 0; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn: any) => conn.respond_with_success(), + onRequest: (req: any) => { + requestCount++; + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callbackCount++; + if (callbackCount === 1) { + callback(null, { + token: 'first-token', + expires: Date.now() + 3600000, + issued: Date.now(), + } as any); + } else { + callback(new Error('Cannot obtain new token')); + } + }, + } as any); + + // First authorize succeeds + await client.auth.authorize(); + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.token).to.equal('first-token'); + + // Make a request that fails with 40142, renewal will also fail + try { + await client.stats({} as any); + } catch (e) { + // Expected — renewal failed + } + + // Spec (RSA16d): after failed renewal, tokenDetails MUST be null. + // DEVIATION: ably-js may keep the stale token. See deviations.md. + expect(callbackCount).to.equal(2); + expect(client.auth.tokenDetails).to.be.null; + }); + + /** + * RSA16d - tokenDetails null with basic auth + */ + it('RSA16d - tokenDetails null with basic auth', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(client.auth.tokenDetails).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA16d - tokenDetails null before first token obtained + */ + it('RSA16d - tokenDetails null before first token', function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, 'my-token'); + }, + } as any); + + // No requests made yet + expect(client.auth.tokenDetails).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * Edge case: tokenDetails preserved across multiple successful requests + */ + it('tokenDetails preserved across requests', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, { + token: 'stable-token', + expires: Date.now() + 3600000, + issued: Date.now(), + clientId: 'stable-client', + } as any); + }, + } as any); + + // Make multiple requests + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const firstCheck = client.auth.tokenDetails; + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const secondCheck = client.auth.tokenDetails; + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const thirdCheck = client.auth.tokenDetails; + + expect(firstCheck!.token).to.equal('stable-token'); + expect(secondCheck!.token).to.equal('stable-token'); + expect(thirdCheck!.token).to.equal('stable-token'); + }); + + /** + * Edge case: tokenDetails reflects capability from token + */ + it('tokenDetails reflects capability', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + callback(null, { + token: 'capable-token', + expires: Date.now() + 3600000, + issued: Date.now(), + capability: '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}', + } as any); + }, + } as any); + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(client.auth.tokenDetails).to.not.be.null; + expect(client.auth.tokenDetails!.capability).to.equal( + '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}', + ); + }); +}); diff --git a/test/uts/rest/auth/token_renewal.test.ts b/test/uts/rest/auth/token_renewal.test.ts new file mode 100644 index 0000000000..7de9edbeda --- /dev/null +++ b/test/uts/rest/auth/token_renewal.test.ts @@ -0,0 +1,416 @@ +/** + * UTS: Token Renewal Tests + * + * Spec points: RSA4a2, RSA4b, RSA4b1, RSC10 + * Source: specification/uts/rest/unit/auth/token_renewal.md + * + * These tests verify that the library correctly handles token expiry: + * - Transparent retry on 40142/40140 server rejection + * - No retry when no renewal mechanism is available + * - Non-token 401 errors are not retried + * + * NOTE: ably-js has a header-overwrite bug in Resource.do() — see deviations.md. + * The retry path passes merged headers (including old authorization) to + * withAuthDetails, which overwrites the new auth header with the old one. + * Tests here use requestCount-based mocking to avoid triggering infinite loops. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/auth/token_renewal', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA4b - Token renewal on 40142 (token expired) + * + * When a request is rejected with 40142, the library obtains a new + * token via authCallback and retries the request. + */ + it('RSA4b - renewal on 40142 error', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + let callbackCount = 0; + let requestCount = 0; + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + if (requestCount === 1) { + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackCount++; + callback(null, 'token-' + callbackCount); + }, + }); + + try { + await client.stats({} as any); + } catch (e) { + /* response parse ok */ + } + + // authCallback called twice: initial + renewal + expect(callbackCount).to.equal(2); + // Two HTTP requests: original + retry + expect(requestCount).to.equal(2); + + // First request used first token + const expectedAuth1 = 'Bearer ' + Buffer.from('token-1').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth1); + + // Second request should use renewed token (token-2) + // NOTE: ably-js has a header-overwrite bug — see deviations.md + const expectedAuth2 = 'Bearer ' + Buffer.from('token-2').toString('base64'); + expect(captured[1].headers.authorization).to.equal(expectedAuth2); + }); + + /** + * RSA4b - Token renewal on 40140 error + */ + it('RSA4b - renewal on 40140 error', async function () { + let callbackCount = 0; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + if (requestCount === 1) { + req.respond_with(401, { + error: { code: 40140, statusCode: 401, message: 'Token error' }, + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackCount++; + callback(null, 'token-' + callbackCount); + }, + }); + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(callbackCount).to.equal(2); + expect(requestCount).to.equal(2); + }); + + /** + * RSA4a2 - No renewal without authCallback/authUrl/key + * + * When the client has only a static token and no way to renew, + * the error should be indicated with code 40171 (not retry). + */ + it('RSA4a2 - no renewal without callback', async function () { + this.timeout(5000); + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ token: 'static-token' }); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error: any) { + // RSA4a2: client must indicate error with code 40171 + expect(error.code).to.equal(40171); + } + + // RSA4a2: only 1 request (no retry without renewal mechanism) + expect(requestCount).to.equal(1); + }); + + /** + * RSA4b - Renewal with authUrl + */ + it('RSA4b - renewal with authUrl', async function () { + let authUrlCallCount = 0; + let apiRequestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + if (req.url.host === 'auth.example.com') { + authUrlCallCount++; + req.respond_with(200, 'token-' + authUrlCallCount, { 'content-type': 'text/plain' }); + } else { + apiRequestCount++; + if (apiRequestCount === 1) { + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + req.respond_with(200, []); + } + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + }); + + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + + expect(authUrlCallCount).to.equal(2); + expect(apiRequestCount).to.equal(2); + }); + + /** + * RSC10 - REST request retried transparently after token renewal + * + * Uses requestCount-based mocking to avoid triggering the ably-js + * header-overwrite bug (see deviations.md). + */ + it('RSC10 - transparent retry after renewal', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + let callbackCount = 0; + let requestCount = 0; + const captured: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + if (requestCount === 1) { + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackCount++; + callback(null, 'token-' + callbackCount); + }, + }); + + // This should succeed transparently despite the first 40142 + try { + await client.stats({} as any); + } catch (e) { + /* response parse ok */ + } + + expect(callbackCount).to.equal(2); + expect(captured).to.have.length(2); + + // First request used first token + const expectedAuth1 = 'Bearer ' + Buffer.from('token-1').toString('base64'); + expect(captured[0].headers.authorization).to.equal(expectedAuth1); + + // Second request should use renewed token + // NOTE: ably-js has a header-overwrite bug — see deviations.md + const expectedAuth2 = 'Bearer ' + Buffer.from('token-2').toString('base64'); + expect(captured[1].headers.authorization).to.equal(expectedAuth2); + }); + + /** + * RSC10 - Non-token 401 errors MUST NOT trigger renewal + * + * Only errors with codes 40140-40149 trigger renewal. Other 401 + * errors (e.g. 40100) are propagated immediately. + */ + it('RSC10 - non-token 401 no renewal', async function () { + let callbackCount = 0; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(401, { + error: { code: 40100, statusCode: 401, message: 'Unauthorized' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackCount++; + callback(null, 'token-' + callbackCount); + }, + }); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + } + + expect(requestCount).to.equal(1); + expect(callbackCount).to.equal(1); + }); + + /** + * RSA4b1 - Token renewal when expired token is used + * + * Per RSA4b1, pre-emptive local expiry detection is only active when + * the server time offset is known (via queryTime). Without queryTime, + * ably-js sends the expired token, the server rejects it with 40142, + * and the library renews. + * + * This test verifies the full flow: expired token → server rejection → + * renewal → successful retry. + */ + it('RSA4b1 - renewal when expired token is rejected', async function () { + let callbackCount = 0; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + // First request (with expired token) fails; second succeeds + if (requestCount === 1) { + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackCount++; + if (callbackCount === 1) { + // First token is already expired + callback(null, { + token: 'expired-token', + expires: Date.now() - 1000, + issued: Date.now() - 3600000, + } as any); + } else { + callback(null, { + token: 'fresh-token', + expires: Date.now() + 3600000, + issued: Date.now(), + } as any); + } + }, + }); + + // Force initial token acquisition + await client.auth.authorize(); + expect(callbackCount).to.equal(1); + + // Request uses expired token → server rejects → renewal → retry + try { + await client.channels.get('test').history({} as any); + } catch (e) { + /* ok */ + } + + // Callback called twice: initial + renewal after 40142 + expect(callbackCount).to.equal(2); + // 2 HTTP requests: failed with expired token + retry with fresh token + expect(requestCount).to.equal(2); + }); + + /** + * RSA4b - Renewal limit (max 1 retry per spec) + * + * If the renewed token is also rejected, the error should propagate. + * + * NOTE: ably-js has no built-in renewal limit — the retry loop in + * Resource.do() is unbounded. Combined with the header-overwrite bug, + * this causes an infinite loop. The authCallback caps retries to + * prevent OOM. See deviations.md. + */ + it('RSA4b - renewal limit', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + this.timeout(5000); + + let callbackCount = 0; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(401, { + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callbackCount++; + if (callbackCount > 3) { + // Cap retries to prevent infinite loop (ably-js has no limit) + callback(new Error('Token renewal limit exceeded') as any, null); + return; + } + callback(null, 'token-' + callbackCount); + }, + }); + + try { + await client.stats({} as any); + expect.fail('Expected request to throw'); + } catch (error) { + expect(error).to.exist; + } + + // Spec (RSA4b): exactly 2 callbacks (initial + 1 renewal), 2 requests. + // DEVIATION: ably-js has no renewal limit — unbounded retry loop. + // The authCallback caps at 3 to prevent OOM. See deviations.md. + expect(callbackCount).to.equal(2); + expect(requestCount).to.equal(2); + }); +}); diff --git a/test/uts/rest/auth/token_request_params.test.ts b/test/uts/rest/auth/token_request_params.test.ts new file mode 100644 index 0000000000..816e2e4b5e --- /dev/null +++ b/test/uts/rest/auth/token_request_params.test.ts @@ -0,0 +1,134 @@ +/** + * UTS: Token Request Parameter Defaults + * + * Spec points: RSA5, RSA6, RSA9 + * Source: specification/uts/rest/unit/auth/token_request_params.md + * + * Tests createTokenRequest() handling of ttl and capability defaults. + * These are local signing operations — no HTTP requests needed. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/auth/token_request_params', function () { + afterEach(function () { + restoreAll(); + }); + + // Install a mock so the client can be constructed (even though + // createTokenRequest doesn't make HTTP calls). + function setup() { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + } + + /** + * RSA5 - TTL is null when not specified + */ + it('RSA5 - TTL is null when not specified', async function () { + setup(); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + const tokenRequest = await client.auth.createTokenRequest(null, null); + + // TTL should be null/undefined, not defaulted to 3600000 + expect(tokenRequest.ttl).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA5b - Explicit TTL is preserved + */ + it('RSA5b - Explicit TTL is preserved', async function () { + setup(); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + const tokenRequest = await client.auth.createTokenRequest({ ttl: 7200000 }, null); + + expect(tokenRequest.ttl).to.equal(7200000); + }); + + /** + * RSA5c - TTL from defaultTokenParams is used + */ + it('RSA5c - TTL from defaultTokenParams is used', async function () { + setup(); + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + defaultTokenParams: { ttl: 1800000 }, + }); + const tokenRequest = await client.auth.createTokenRequest(null, null); + + expect(tokenRequest.ttl).to.equal(1800000); + }); + + /** + * RSA5d - Explicit TTL overrides defaultTokenParams + */ + it('RSA5d - Explicit TTL overrides defaultTokenParams', async function () { + setup(); + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + defaultTokenParams: { ttl: 1800000 }, + }); + const tokenRequest = await client.auth.createTokenRequest({ ttl: 600000 }, null); + + expect(tokenRequest.ttl).to.equal(600000); + }); + + /** + * RSA6 - Capability is null when not specified + */ + it('RSA6 - Capability is null when not specified', async function () { + setup(); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + const tokenRequest = await client.auth.createTokenRequest(null, null); + + // Capability should be null/undefined, not defaulted to '{"*":["*"]}' + expect(tokenRequest.capability).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * RSA6b - Explicit capability is preserved + */ + it('RSA6b - Explicit capability is preserved', async function () { + setup(); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + const tokenRequest = await client.auth.createTokenRequest( + { capability: '{"channel-a":["publish","subscribe"]}' }, + null, + ); + + expect(tokenRequest.capability).to.equal('{"channel-a":["publish","subscribe"]}'); + }); + + /** + * RSA6c - Capability from defaultTokenParams is used + */ + it('RSA6c - Capability from defaultTokenParams is used', async function () { + setup(); + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + defaultTokenParams: { capability: '{"*":["subscribe"]}' }, + }); + const tokenRequest = await client.auth.createTokenRequest(null, null); + + expect(tokenRequest.capability).to.equal('{"*":["subscribe"]}'); + }); + + /** + * RSA6d - Explicit capability overrides defaultTokenParams + */ + it('RSA6d - Explicit capability overrides defaultTokenParams', async function () { + setup(); + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + defaultTokenParams: { capability: '{"*":["subscribe"]}' }, + }); + const tokenRequest = await client.auth.createTokenRequest({ capability: '{"channel-x":["publish"]}' }, null); + + expect(tokenRequest.capability).to.equal('{"channel-x":["publish"]}'); + }); +}); diff --git a/test/uts/rest/batch_presence.test.ts b/test/uts/rest/batch_presence.test.ts new file mode 100644 index 0000000000..26e5d3755c --- /dev/null +++ b/test/uts/rest/batch_presence.test.ts @@ -0,0 +1,453 @@ +/** + * UTS: Batch Presence Tests + * + * Spec points: RSC24, BAR2, BGR2, BGF2 + * Source: specification/uts/rest/unit/batch_presence.md + * + * Tests for RestClient#batchPresence: sends GET to /presence with channel + * names as a comma-separated query parameter, returns per-channel results. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/batch_presence', function () { + afterEach(function () { + restoreAll(); + }); + + // --------------------------------------------------------------------------- + // RSC24 - batchPresence sends GET to /presence + // --------------------------------------------------------------------------- + + describe('RSC24 - batchPresence sends GET to /presence', function () { + it('RSC24_1 - sends GET request to /presence with channels query param', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + successCount: 2, + failureCount: 0, + results: [ + { channel: 'channel-a', presence: [] }, + { channel: 'channel-b', presence: [] }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPresence(['channel-a', 'channel-b']); + + expect(captured).to.have.length(1); + expect(captured[0].method.toUpperCase()).to.equal('GET'); + expect(captured[0].path).to.equal('/presence'); + expect(captured[0].url.searchParams.get('channels')).to.equal('channel-a,channel-b'); + }); + + it('RSC24_2 - single channel sends GET with single channel name', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + successCount: 1, + failureCount: 0, + results: [{ channel: 'my-channel', presence: [] }], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPresence(['my-channel']); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('channels')).to.equal('my-channel'); + }); + + it('RSC24_3 - channel names with special characters are comma-joined', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + successCount: 2, + failureCount: 0, + results: [ + { channel: 'foo:bar', presence: [] }, + { channel: 'baz/qux', presence: [] }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPresence(['foo:bar', 'baz/qux']); + + expect(captured).to.have.length(1); + // The SDK joins channels with comma; URL encoding may apply + const channelsParam = captured[0].url.searchParams.get('channels'); + expect(channelsParam).to.equal('foo:bar,baz/qux'); + }); + }); + + // --------------------------------------------------------------------------- + // BAR2 - BatchPresenceResponse structure + // --------------------------------------------------------------------------- + + describe('BAR2 - BatchPresenceResponse structure', function () { + it('BAR2_2 - all success', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + successCount: 2, + failureCount: 0, + results: [ + { channel: 'ch-a', presence: [] }, + { channel: 'ch-b', presence: [] }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['ch-a', 'ch-b']); + + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(0); + expect(result.results).to.have.lengthOf(2); + }); + + /** + * BAR2_1 - Mixed results with computed counts + * + * Per spec: the SDK should normalise the HTTP 400 response containing + * {error, batchResponse} into {successCount, failureCount, results}. + */ + it('BAR2_1 - mixed results normalised', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { channel: 'ch-1', presence: [] }, + { channel: 'ch-2', presence: [] }, + { channel: 'ch-3', presence: [] }, + { channel: 'ch-4', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['ch-1', 'ch-2', 'ch-3', 'ch-4']); + + expect(result.successCount).to.equal(3); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.length(4); + }); + + /** + * BAR2_3 - All failure + * + * Per spec: the SDK should normalise the HTTP 400 response into + * {successCount: 0, failureCount: N, results}. + */ + it('BAR2_3 - all failure normalised', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { channel: 'ch-a', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, + { channel: 'ch-b', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['ch-a', 'ch-b']); + + expect(result.successCount).to.equal(0); + expect(result.failureCount).to.equal(2); + expect(result.results).to.have.length(2); + }); + }); + + // --------------------------------------------------------------------------- + // BGR2 - BatchPresenceSuccessResult structure + // --------------------------------------------------------------------------- + + describe('BGR2 - BatchPresenceSuccessResult structure', function () { + it('BGR2_1 - success result with members present', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + successCount: 1, + failureCount: 0, + results: [ + { + channel: 'my-channel', + presence: [ + { + clientId: 'client-1', + action: 1, + connectionId: 'conn-abc', + id: 'conn-abc:0:0', + timestamp: 1700000000000, + data: 'hello', + }, + { + clientId: 'client-2', + action: 1, + connectionId: 'conn-def', + id: 'conn-def:0:0', + timestamp: 1700000000000, + data: '{"key":"value"}', + }, + ], + }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['my-channel']); + + expect(result.results).to.have.lengthOf(1); + + const success = result.results[0] as any; + expect(success.channel).to.equal('my-channel'); + expect(success.presence).to.be.an('array').with.lengthOf(2); + expect(success.presence[0].clientId).to.equal('client-1'); + expect(success.presence[0].connectionId).to.equal('conn-abc'); + expect(success.presence[1].clientId).to.equal('client-2'); + }); + + it('BGR2_2 - success result with empty presence (no members)', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + successCount: 1, + failureCount: 0, + results: [{ channel: 'empty-channel', presence: [] }], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['empty-channel']); + + const success = result.results[0] as any; + expect(success.channel).to.equal('empty-channel'); + expect(success.presence).to.be.an('array').with.lengthOf(0); + }); + }); + + // --------------------------------------------------------------------------- + // BGF2 - BatchPresenceFailureResult structure + // --------------------------------------------------------------------------- + + describe('BGF2 - BatchPresenceFailureResult structure', function () { + /** + * BGF2_1 - Failure result with error details + * + * Per spec: the SDK should normalise the HTTP 400 response so that + * per-channel failure results with error details are accessible. + */ + it('BGF2_1 - failure result normalised with error details', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { + channel: 'restricted-channel', + error: { + code: 40160, + statusCode: 401, + message: 'Channel operation not permitted', + }, + }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['restricted-channel']); + + expect(result.successCount).to.equal(0); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.length(1); + expect(result.results[0].channel).to.equal('restricted-channel'); + expect((result.results[0] as any).error.code).to.equal(40160); + }); + }); + + // --------------------------------------------------------------------------- + // Mixed results + // --------------------------------------------------------------------------- + + describe('Mixed results', function () { + /** + * RSC24_Mixed_1 - Mixed success and failure results + * + * Per spec: the SDK should normalise the batchResponse into per-channel + * success/failure results with computed counts. + */ + it('RSC24_Mixed_1 - mixed results normalised', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40020, statusCode: 400, message: 'Batched response includes errors' }, + batchResponse: [ + { + channel: 'allowed-channel', + presence: [ + { + clientId: 'user-1', + action: 1, + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1700000000000, + }, + ], + }, + { + channel: 'restricted-channel', + error: { + code: 40160, + statusCode: 401, + message: 'Not permitted', + }, + }, + ], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPresence(['allowed-channel', 'restricted-channel']); + + expect(result.successCount).to.equal(1); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.length(2); + }); + }); + + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + describe('Error handling', function () { + it('RSC24_Error_1 - server error is propagated', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + error: { code: 50000, statusCode: 500, message: 'Internal error' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + let threw = false; + try { + await client.batchPresence(['any-channel']); + } catch (err: any) { + threw = true; + expect(err.code).to.equal(50000); + expect(err.statusCode).to.equal(500); + } + expect(threw).to.be.true; + }); + + it('RSC24_Error_2 - authentication error is propagated', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + error: { code: 40101, statusCode: 401, message: 'Invalid credentials' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + let threw = false; + try { + await client.batchPresence(['any-channel']); + } catch (err: any) { + threw = true; + expect(err.code).to.equal(40101); + expect(err.statusCode).to.equal(401); + } + expect(threw).to.be.true; + }); + }); + + // --------------------------------------------------------------------------- + // RSC24_Auth - request authentication + // --------------------------------------------------------------------------- + + describe('RSC24_Auth - request authentication', function () { + it('RSC24_Auth_1 - basic auth header is included', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', presence: [] }], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPresence(['ch']); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('authorization'); + expect(captured[0].headers['authorization']).to.match(/^Basic /); + }); + }); +}); diff --git a/test/uts/rest/batch_publish.test.ts b/test/uts/rest/batch_publish.test.ts new file mode 100644 index 0000000000..f2e114b47a --- /dev/null +++ b/test/uts/rest/batch_publish.test.ts @@ -0,0 +1,784 @@ +/** + * UTS: Batch Publish Tests + * + * Spec points: RSC22, RSC22c, RSC22d, BSP2a, BSP2b, BPR2a-c, BPF2a-b + * Source: specification/uts/rest/unit/batch_publish.md + * + * Batch Presence tests are in batch_presence.test.ts. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/batch_publish', function () { + afterEach(function () { + restoreAll(); + }); + + // --------------------------------------------------------------------------- + // RSC22c - batchPublish sends POST to /messages + // --------------------------------------------------------------------------- + + describe('RSC22c - batchPublish sends POST to /messages', function () { + it('RSC22c1 - single BatchPublishSpec sends POST to /messages', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch1', messageId: 'msg123', serials: ['s1'] }], + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['ch1'], + messages: [{ name: 'event', data: 'hello' }], + }); + + expect(captured).to.have.length(1); + expect(captured[0].method.toUpperCase()).to.equal('POST'); + expect(captured[0].path).to.equal('/messages'); + + const body = JSON.parse(captured[0].body); + // Single spec is wrapped in an array by the SDK + expect(body).to.be.an('array').with.lengthOf(1); + expect(body[0].channels).to.deep.equal(['ch1']); + expect(body[0].messages).to.be.an('array').with.lengthOf(1); + expect(body[0].messages[0].name).to.equal('event'); + expect(body[0].messages[0].data).to.equal('hello'); + }); + + it('RSC22c2 - array of BatchPublishSpecs sends POST to /messages', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch-a', messageId: 'msg1', serials: ['s1'] }], + }, + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch-b', messageId: 'msg2', serials: ['s2'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish([ + { channels: ['ch-a'], messages: [{ name: 'e1', data: 'd1' }] }, + { channels: ['ch-b'], messages: [{ name: 'e2', data: 'd2' }] }, + ]); + + expect(captured).to.have.length(1); + expect(captured[0].method.toUpperCase()).to.equal('POST'); + expect(captured[0].path).to.equal('/messages'); + + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array').with.lengthOf(2); + expect(body[0].channels).to.deep.equal(['ch-a']); + expect(body[0].messages[0].name).to.equal('e1'); + expect(body[1].channels).to.deep.equal(['ch-b']); + expect(body[1].messages[0].name).to.equal('e2'); + }); + + it('RSC22c3 - single spec returns single BatchResult (not array)', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + // Server returns array of BatchResult, SDK unwraps first element for single spec + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch1', messageId: 'msg123', serials: ['serial1'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['ch1'], + messages: [{ name: 'event', data: 'data' }], + }); + + // Single spec returns a single BatchResult, not an array + expect(result).to.not.be.an('array'); + expect(result).to.have.property('successCount', 1); + expect(result).to.have.property('failureCount', 0); + expect(result.results).to.be.an('array').with.lengthOf(1); + expect(result.results[0].channel).to.equal('ch1'); + }); + + it('RSC22c4 - array of specs returns array of BatchResults', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch-a', messageId: 'msg1', serials: ['s1'] }], + }, + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch-b', messageId: 'msg2', serials: ['s2'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const results = await client.batchPublish([ + { channels: ['ch-a'], messages: [{ name: 'e1', data: 'd1' }] }, + { channels: ['ch-b'], messages: [{ name: 'e2', data: 'd2' }] }, + ]); + + expect(results).to.be.an('array').with.lengthOf(2); + expect(results[0].results[0].channel).to.equal('ch-a'); + expect((results[0].results[0] as any).messageId).to.equal('msg1'); + expect(results[1].results[0].channel).to.equal('ch-b'); + expect((results[1].results[0] as any).messageId).to.equal('msg2'); + }); + + it('RSC22c5 - multiple channels in spec produces multiple results', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 3, + failureCount: 0, + results: [ + { channel: 'ch-1', messageId: 'msg1', serials: ['s1'] }, + { channel: 'ch-2', messageId: 'msg2', serials: ['s2'] }, + { channel: 'ch-3', messageId: 'msg3', serials: ['s3'] }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['ch-1', 'ch-2', 'ch-3'], + messages: [{ name: 'event', data: 'data' }], + }); + + expect(result.successCount).to.equal(3); + expect(result.results).to.have.lengthOf(3); + expect(result.results[0].channel).to.equal('ch-1'); + expect(result.results[1].channel).to.equal('ch-2'); + expect(result.results[2].channel).to.equal('ch-3'); + }); + }); + + // --------------------------------------------------------------------------- + // RSC22c7 - Request uses correct authentication + // --------------------------------------------------------------------------- + + describe('RSC22c7 - authentication', function () { + it('RSC22c7 - basic auth header is included', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['s'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('authorization'); + expect(captured[0].headers['authorization']).to.match(/^Basic /); + }); + }); + + // --------------------------------------------------------------------------- + // BPR - BatchPublishSuccessResult structure + // --------------------------------------------------------------------------- + + describe('BPR - BatchPublishSuccessResult structure', function () { + it('BPR2a - channel field contains channel name', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'my-channel', messageId: 'msg123', serials: ['s1'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['my-channel'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(result.results[0].channel).to.equal('my-channel'); + }); + + it('BPR2b - messageId contains the message ID prefix', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'unique-id-prefix', serials: ['s1', 's2'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['ch'], + messages: [ + { name: 'e1', data: 'd1' }, + { name: 'e2', data: 'd2' }, + ], + }); + + expect((result.results[0] as any).messageId).to.equal('unique-id-prefix'); + }); + + it('BPR2c - serials contains array of message serials', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['serial1', 'serial2', 'serial3'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['ch'], + messages: [ + { name: 'e1', data: 'd1' }, + { name: 'e2', data: 'd2' }, + { name: 'e3', data: 'd3' }, + ], + }); + + expect((result.results[0] as any).serials).to.deep.equal(['serial1', 'serial2', 'serial3']); + }); + + it('BPR2c1 - serials may contain null for conflated messages', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['serial1', null, 'serial3'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['ch'], + messages: [ + { name: 'e1', data: 'd1' }, + { name: 'e2', data: 'd2' }, + { name: 'e3', data: 'd3' }, + ], + }); + + expect((result.results[0] as any).serials).to.deep.equal(['serial1', null, 'serial3']); + }); + }); + + // --------------------------------------------------------------------------- + // BPF - BatchPublishFailureResult structure + // --------------------------------------------------------------------------- + + describe('BPF - BatchPublishFailureResult structure', function () { + it('BPF2a - channel field contains failed channel name', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 0, + failureCount: 1, + results: [ + { channel: 'restricted-ch', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['restricted-ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(result.results[0].channel).to.equal('restricted-ch'); + }); + + it('BPF2b - error contains ErrorInfo for failure reason', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 0, + failureCount: 1, + results: [ + { + channel: 'restricted-ch', + error: { + code: 40160, + statusCode: 401, + message: 'Channel operation not permitted', + }, + }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['restricted-ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect((result.results[0] as any).error).to.exist; + expect((result.results[0] as any).error.code).to.equal(40160); + expect((result.results[0] as any).error.statusCode).to.equal(401); + expect((result.results[0] as any).error.message).to.include('not permitted'); + }); + }); + + // --------------------------------------------------------------------------- + // BatchResult - Mixed success and failure + // --------------------------------------------------------------------------- + + describe('BatchResult - mixed success and failure', function () { + it('BatchResult1 - partial success with mixed results', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 1, + failureCount: 1, + results: [ + { channel: 'allowed-ch', messageId: 'msg1', serials: ['s1'] }, + { channel: 'restricted-ch', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['allowed-ch', 'restricted-ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(result.successCount).to.equal(1); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.lengthOf(2); + + // Success result has messageId, no error + expect(result.results[0].channel).to.equal('allowed-ch'); + expect((result.results[0] as any).messageId).to.equal('msg1'); + expect('error' in result.results[0]).to.be.false; + + // Failure result has error, no messageId + expect(result.results[1].channel).to.equal('restricted-ch'); + expect((result.results[1] as any).error.code).to.equal(40160); + expect('messageId' in result.results[1]).to.be.false; + }); + }); + + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + describe('Error handling', function () { + it('RSC22_Error3 - server error returns error', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + error: { code: 50000, statusCode: 500, message: 'Internal error' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + let threw = false; + try { + await client.batchPublish({ + channels: ['ch'], + messages: [{ name: 'e', data: 'd' }], + }); + } catch (err: any) { + threw = true; + expect(err.code).to.equal(50000); + expect(err.statusCode).to.equal(500); + } + expect(threw).to.be.true; + }); + + it('RSC22_Error4 - authentication error returns error', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + error: { code: 40101, statusCode: 401, message: 'Invalid credentials' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + let threw = false; + try { + await client.batchPublish({ + channels: ['ch'], + messages: [{ name: 'e', data: 'd' }], + }); + } catch (err: any) { + threw = true; + expect(err.code).to.equal(40101); + expect(err.statusCode).to.equal(401); + } + expect(threw).to.be.true; + }); + }); + + // --------------------------------------------------------------------------- + // RSC22_Headers - request headers + // --------------------------------------------------------------------------- + + describe('RSC22_Headers - request headers', function () { + it('RSC22_Headers1 - standard headers included', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['s'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('X-Ably-Version'); + expect(captured[0].headers['X-Ably-Version']).to.match(/[0-9.]+/); + expect(captured[0].headers).to.have.property('Ably-Agent'); + expect(captured[0].headers['content-type']).to.include('application/json'); + }); + }); + + // --------------------------------------------------------------------------- + // BSP - BatchPublishSpec structure + // --------------------------------------------------------------------------- + + describe('BSP - BatchPublishSpec structure', function () { + it('BSP2a - channels is array of strings', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 3, + failureCount: 0, + results: [ + { channel: 'ch-a', messageId: 'msg', serials: ['s'] }, + { channel: 'ch-b', messageId: 'msg', serials: ['s'] }, + { channel: 'ch-c', messageId: 'msg', serials: ['s'] }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['ch-a', 'ch-b', 'ch-c'], + messages: [{ name: 'e', data: 'd' }], + }); + + const body = JSON.parse(captured[0].body); + expect(body[0].channels).to.be.an('array').with.lengthOf(3); + expect(body[0].channels).to.deep.equal(['ch-a', 'ch-b', 'ch-c']); + }); + + it('BSP2b - messages is array of Message objects', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['s1', 's2'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['ch'], + messages: [ + { name: 'event1', data: 'data1' }, + { name: 'event2', data: JSON.stringify({ key: 'value' }) }, + ], + }); + + const body = JSON.parse(captured[0].body); + expect(body[0].messages).to.be.an('array').with.lengthOf(2); + expect(body[0].messages[0].name).to.equal('event1'); + expect(body[0].messages[0].data).to.equal('data1'); + expect(body[0].messages[1].name).to.equal('event2'); + }); + }); + + // --------------------------------------------------------------------------- + // RSC22d - idempotent publish with idempotentRestPublishing + // --------------------------------------------------------------------------- + + describe('RSC22d - idempotent batch publish', function () { + /** + * RSC22d - batch publish generates idempotent IDs per RSL1k1 + * + * Per spec: "If idempotentRestPublishing is enabled, then RSL1k1 should + * be applied (to each BatchPublishSpec separately)." + */ + it('RSC22d - batch publish generates idempotent IDs', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch1', messageId: 'msg-id-1', serials: ['s1'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + await client.batchPublish({ + channels: ['ch1'], + messages: [{ name: 'event', data: 'data' }], + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body[0].messages[0]).to.have.property('id'); + expect(body[0].messages[0].id).to.match(/^.+:0$/); + }); + }); + + // --------------------------------------------------------------------------- + // RSC22_Error - edge cases + // --------------------------------------------------------------------------- + + describe('RSC22_Error - edge cases', function () { + it('RSC22_Error1 - empty channels array', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(400, { + error: { code: 40000, statusCode: 400, message: 'No channels specified' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + let threw = false; + try { + await client.batchPublish({ + channels: [], + messages: [{ name: 'e', data: 'd' }], + }); + } catch (err: any) { + threw = true; + // Either the SDK validates locally or the server rejects it + expect(err.code).to.be.a('number'); + } + + // Either an error is thrown or the request was made with the empty array + if (!threw) { + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body[0].channels).to.deep.equal([]); + } + }); + }); + + // --------------------------------------------------------------------------- + // RSC22c6 - encoding in batch messages + // --------------------------------------------------------------------------- + + describe('RSC22c6 - encoding in batch messages', function () { + it('RSC22c6 - JSON string data sent correctly in body', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['s1'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['ch'], + messages: [{ name: 'event', data: JSON.stringify({ key: 'value' }) }], + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body[0].messages[0].name).to.equal('event'); + // The data should be the JSON string as-is + const parsedData = JSON.parse(body[0].messages[0].data); + expect(parsedData).to.deep.equal({ key: 'value' }); + }); + }); + + // --------------------------------------------------------------------------- + // BSP - additional BatchPublishSpec tests + // --------------------------------------------------------------------------- + + describe('BSP - additional BatchPublishSpec tests', function () { + it('BSP - single channel in BatchPublishSpec', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'single-ch', messageId: 'msg', serials: ['s1'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.batchPublish({ + channels: ['single-ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + // Single spec is wrapped in an array + expect(body).to.be.an('array').with.lengthOf(1); + expect(body[0].channels).to.deep.equal(['single-ch']); + expect(body[0].messages).to.be.an('array').with.lengthOf(1); + expect(body[0].messages[0].name).to.equal('e'); + expect(body[0].messages[0].data).to.equal('d'); + }); + }); +}); diff --git a/test/uts/rest/channel/annotations.test.ts b/test/uts/rest/channel/annotations.test.ts new file mode 100644 index 0000000000..80b5ac6bcf --- /dev/null +++ b/test/uts/rest/channel/annotations.test.ts @@ -0,0 +1,384 @@ +/** + * UTS: REST Channel Annotations Tests + * + * Spec points: RSL10, RSAN1, RSAN1a3, RSAN1c3, RSAN1c4, RSAN2a, RSAN3b, RSAN3c + * Source: uts/test/rest/unit/channel/annotations.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/channel/annotations', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL10 - channel.annotations is accessible + * + * The channel must expose an annotations attribute that is an object + * (specifically a RestAnnotations instance). + */ + it('RSL10 - channel.annotations is accessible', function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-RSL10'); + + expect(ch.annotations).to.be.an('object'); + expect(ch.annotations).to.not.be.null; + expect(ch.annotations).to.not.be.undefined; + }); + + /** + * RSAN1 - publish sends POST with ANNOTATION_CREATE + * + * annotations.publish() must send a POST request to the correct endpoint + * with the annotation body containing action=0 (ANNOTATION_CREATE), + * the messageSerial, type, and name fields. + */ + it('RSAN1 - publish sends POST with ANNOTATION_CREATE', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.annotations.publish('msg-serial-1', { type: 'com.example.reaction', name: 'like' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('post'); + expect(captured[0].path).to.equal('/channels/test/messages/msg-serial-1/annotations'); + + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].action).to.equal(0); // ANNOTATION_CREATE + expect(body[0].messageSerial).to.equal('msg-serial-1'); + expect(body[0].type).to.equal('com.example.reaction'); + expect(body[0].name).to.equal('like'); + }); + + /** + * RSAN1a3 - type required + * + * Publishing an annotation without a type field should throw an error + * with code 40003. + * + * NOTE: ably-js does not currently validate the type field in + * constructValidateAnnotation(). This test documents the spec + * requirement (RSAN1a3) as a known deviation — the publish succeeds + * without a type instead of throwing. + */ + it('RSAN1a3 - type required', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + + // Spec (RSAN1a3): publishing without a type MUST throw with code 40003. + // DEVIATION: ably-js does not validate type. See deviations.md. + try { + await ch.annotations.publish('msg-serial-1', { name: 'like' }); + expect.fail('Expected publish without type to throw with code 40003'); + } catch (error: any) { + expect(error.code).to.equal(40003); + } + }); + + /** + * RSAN1c3 - data encoded per RSL4 + * + * When annotation data is a JSON object, it must be encoded as a + * JSON string with the encoding field set to 'json', following RSL4 + * message encoding rules. + */ + it('RSAN1c3 - data encoded per RSL4', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.annotations.publish('msg-serial-1', { type: 'com.example.data', data: { key: 'value' } }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.have.length(1); + + // JSON data should be encoded as a string with encoding 'json' + expect(body[0].data).to.be.a('string'); + expect(body[0].encoding).to.equal('json'); + expect(JSON.parse(body[0].data)).to.deep.equal({ key: 'value' }); + }); + + /** + * RSAN1c4 - idempotent ID generated + * + * When idempotentRestPublishing is true, the annotation's id should + * be auto-generated in the format :0. + * + * NOTE: ably-js does not currently generate idempotent IDs for + * annotations (only for messages via RestChannel.publish). This test + * documents the spec requirement as a known deviation. + */ + it('RSAN1c4 - idempotent ID generated', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + await ch.annotations.publish('msg-serial-1', { type: 'com.example.reaction' }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.have.length(1); + + // Spec (RSAN1c4): annotation id MUST be auto-generated in :0 format. + // DEVIATION: ably-js does not generate idempotent IDs for annotations. See deviations.md. + const id = body[0].id; + expect(id).to.be.a('string'); + const parts = id.split(':'); + expect(parts).to.have.length(2); + expect(parts[0]).to.match(/^[A-Za-z0-9_-]+$/); + expect(parts[0].length).to.be.at.least(12); + expect(parts[1]).to.equal('0'); + }); + + /** + * RSAN1c4 - no ID when disabled + * + * When idempotentRestPublishing is false, no idempotent ID should + * be generated on the annotation. + */ + it('RSAN1c4 - no ID when disabled', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: false, + }); + const ch = client.channels.get('test'); + await ch.annotations.publish('msg-serial-1', { type: 'com.example.reaction' }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.have.length(1); + expect(body[0].id).to.be.undefined; + }); + + /** + * RSAN2a - delete sends POST with ANNOTATION_DELETE + * + * annotations.delete() must send a POST request with + * action=1 (ANNOTATION_DELETE) to the correct endpoint. + */ + it('RSAN2a - delete sends POST with ANNOTATION_DELETE', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.annotations.delete('msg-serial-1', { type: 'com.example.reaction', name: 'like' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('post'); + expect(captured[0].path).to.include('/messages/msg-serial-1/annotations'); + + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].action).to.equal(1); // ANNOTATION_DELETE + expect(body[0].messageSerial).to.equal('msg-serial-1'); + expect(body[0].type).to.equal('com.example.reaction'); + expect(body[0].name).to.equal('like'); + }); + + /** + * RSAN3b - get sends GET to correct path + * + * annotations.get() must send a GET request to + * /channels/{channelName}/messages/{messageSerial}/annotations. + */ + it('RSAN3b - get sends GET to correct path', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + id: 'ann-1', + action: 0, + type: 'com.example.reaction', + name: 'like', + clientId: 'user-1', + serial: 'ann-serial-1', + messageSerial: 'msg-serial-1', + timestamp: 1700000000000, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + const result = await ch.annotations.get('msg-serial-1', {}); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.include('/messages/msg-serial-1/annotations'); + }); + + /** + * RSAN3c - get returns PaginatedResult with annotation fields + * + * The response must be parsed into a PaginatedResult containing + * Annotation objects with all expected fields decoded. + */ + it('RSAN3c - get returns PaginatedResult with annotation fields', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + id: 'ann-1', + action: 0, + type: 'com.example.reaction', + name: 'like', + clientId: 'user-1', + count: 1, + data: 'thumbs-up', + serial: 'ann-serial-1', + messageSerial: 'msg-serial-1', + timestamp: 1700000000000, + extras: { headers: { source: 'web' } }, + }, + { + id: 'ann-2', + action: 0, + type: 'com.example.reaction', + name: 'heart', + clientId: 'user-2', + count: 3, + data: null, + serial: 'ann-serial-2', + messageSerial: 'msg-serial-1', + timestamp: 1700000001000, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + const result = await ch.annotations.get('msg-serial-1', {}); + + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(2); + + // First annotation — full field coverage including extras + const ann = result.items[0]; + expect(ann.id).to.equal('ann-1'); + expect(ann.action).to.equal('annotation.create'); // decoded from wire value 0 + expect(ann.type).to.equal('com.example.reaction'); + expect(ann.name).to.equal('like'); + expect(ann.clientId).to.equal('user-1'); + expect(ann.count).to.equal(1); + expect(ann.data).to.equal('thumbs-up'); + expect(ann.serial).to.equal('ann-serial-1'); + expect(ann.messageSerial).to.equal('msg-serial-1'); + expect(ann.timestamp).to.equal(1700000000000); + expect(ann.extras).to.deep.equal({ headers: { source: 'web' } }); + + // Second annotation — verify multiple items decoded + const ann2 = result.items[1]; + expect(ann2.id).to.equal('ann-2'); + expect(ann2.name).to.equal('heart'); + expect(ann2.clientId).to.equal('user-2'); + expect(ann2.count).to.equal(3); + }); + + /** + * RSAN3b - get passes params as querystring + * + * Optional params passed to annotations.get() must be sent as + * query string parameters on the GET request. + */ + it('RSAN3b - get passes params as querystring', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.annotations.get('msg-serial-1', { limit: '50' } as any); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('50'); + }); +}); diff --git a/test/uts/rest/channel/get_message.test.ts b/test/uts/rest/channel/get_message.test.ts new file mode 100644 index 0000000000..d1c5305eca --- /dev/null +++ b/test/uts/rest/channel/get_message.test.ts @@ -0,0 +1,148 @@ +/** + * UTS: REST Channel getMessage Tests + * + * Spec points: RSL11a, RSL11b, RSL11c + * Source: uts/test/rest/unit/channel/get_message.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/channel/getMessage', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL11b - GET to correct path + * + * getMessage(serial) must send a GET request to + * /channels/{channelName}/messages/{serial}. + */ + it('RSL11b - GET to correct path', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + id: 'msg-id-1', + name: 'test-event', + data: 'hello', + serial: 'msg-serial-123', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.getMessage('msg-serial-123'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/channels/test/messages/msg-serial-123'); + }); + + /** + * RSL11c - returns decoded Message + * + * getMessage must return a single Message object with all fields + * decoded from the response body. + */ + it('RSL11c - returns decoded Message', async function () { + const responseBody = { + id: 'msg-id-1', + name: 'test-event', + data: 'hello world', + serial: 'serial-xyz', + clientId: 'client-1', + timestamp: 1700000000000, + extras: { headers: { source: 'api' } }, + version: { serial: 'vs1', timestamp: 1700000000000, clientId: 'client-1' }, + }; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, responseBody); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + const msg = await ch.getMessage('serial-xyz'); + + expect(msg.id).to.equal('msg-id-1'); + expect(msg.name).to.equal('test-event'); + expect(msg.data).to.equal('hello world'); + expect(msg.serial).to.equal('serial-xyz'); + expect(msg.clientId).to.equal('client-1'); + expect(msg.timestamp).to.equal(1700000000000); + expect(msg.extras).to.deep.equal({ headers: { source: 'api' } }); + expect(msg.version).to.be.an('object'); + expect(msg.version!.serial).to.equal('vs1'); + expect(msg.version!.timestamp).to.equal(1700000000000); + }); + + /** + * RSL11b - URL-encodes serial + * + * When the serial contains characters that are not URL-safe, + * getMessage must URL-encode the serial in the request path. + */ + it('RSL11b - URL-encodes serial', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + id: 'msg-id-1', + name: 'test-event', + data: 'hello', + serial: 'serial/with:special+chars', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.getMessage('serial/with:special+chars'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + // The serial must be URL-encoded in the path + expect(captured[0].path).to.include(encodeURIComponent('serial/with:special+chars')); + expect(captured[0].path).to.not.include('serial/with:special+chars'); + }); + + /** + * RSL11a - empty serial throws 40003 + * + * getMessage must throw an error with code 40003 when called + * with an empty serial string. + */ + it('RSL11a - empty serial throws 40003', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + + try { + await ch.getMessage(''); + expect.fail('Expected getMessage to throw due to empty serial'); + } catch (error: any) { + expect(error.code).to.equal(40003); + } + }); +}); diff --git a/test/uts/rest/channel/history.test.ts b/test/uts/rest/channel/history.test.ts new file mode 100644 index 0000000000..9899a875fd --- /dev/null +++ b/test/uts/rest/channel/history.test.ts @@ -0,0 +1,309 @@ +/** + * UTS: REST Channel History Tests + * + * Spec points: RSL2, RSL2a, RSL2b, RSL2b1, RSL2b2, RSL2b3 + * Source: uts/test/rest/unit/channel/history.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/channel/history', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL2a - history returns PaginatedResult + * + * The history() method must return a PaginatedResult containing + * Message objects deserialized from the response. + */ + it('RSL2a - history returns PaginatedResult', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { id: '1', name: 'a', data: 'x' }, + { id: '2', name: 'b', data: 'y' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + const result = await ch.history(null); + + expect(result.items).to.have.length(2); + expect(result.items[0].name).to.equal('a'); + expect(result.items[0].data).to.equal('x'); + expect(result.items[1].name).to.equal('b'); + expect(result.items[1].data).to.equal('y'); + }); + + /** + * RSL2b - history with start parameter + * + * The start parameter is an optional timestamp (ms since epoch) + * that filters messages to those published at or after that time. + */ + it('RSL2b - history with start parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history({ start: 1000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1000'); + }); + + /** + * RSL2b - history with end parameter + * + * The end parameter is an optional timestamp (ms since epoch) + * that filters messages to those published at or before that time. + */ + it('RSL2b - history with end parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history({ end: 2000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('end')).to.equal('2000'); + }); + + /** + * RSL2b - history with direction parameter + * + * The direction parameter controls the ordering of results: + * 'forwards' or 'backwards'. + */ + it('RSL2b - history with direction parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history({ direction: 'forwards' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('direction')).to.equal('forwards'); + }); + + /** + * RSL2b - history with direction: backwards + */ + it('RSL2b - history with direction backwards', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history({ direction: 'backwards' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('direction')).to.equal('backwards'); + }); + + /** + * RSL2b1 - default direction is backwards + * + * When direction is not specified, it defaults to 'backwards' + * (either omitted from the query or sent as 'backwards'). + */ + it('RSL2b1 - default direction is backwards', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history(null); + + expect(captured).to.have.length(1); + const direction = captured[0].url.searchParams.get('direction'); + expect(direction === null || direction === 'backwards').to.be.true; + }); + + /** + * RSL2b2 - limit parameter + * + * The limit parameter controls the maximum number of results returned. + */ + it('RSL2b2 - limit parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history({ limit: 10 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('10'); + }); + + /** + * RSL2b3 - default limit + * + * When limit is not specified, it defaults to 100 + * (either omitted from the query or sent as '100'). + */ + it('RSL2b3 - default limit', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.history(null); + + expect(captured).to.have.length(1); + const limit = captured[0].url.searchParams.get('limit'); + expect(limit === null || limit === '100').to.be.true; + }); + + /** + * RSL2 - URL encoding of channel name + * + * Channel names containing special characters must be properly + * URL-encoded in the request path. + */ + it('RSL2 - URL encoding of channel name', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channelName = 'ns:my channel'; + const ch = client.channels.get(channelName); + await ch.history(null); + + expect(captured).to.have.length(1); + const expectedPath = `/channels/${encodeURIComponent(channelName)}/messages`; + expect(captured[0].path).to.equal(expectedPath); + }); + + /** + * RSL2 - History with combined time range (start and end) + */ + it('RSL2 - history with start and end time range', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [{ name: 'event', data: 'in-range', timestamp: 1500 }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').history({ start: 1000, end: 2000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1000'); + expect(captured[0].url.searchParams.get('end')).to.equal('2000'); + }); + + /** + * RSL2 - URL encoding with colon in channel name + */ + it('RSL2 - URL encoding with colon', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('namespace:channel').history(null); + + expect(captured).to.have.length(1); + expect(captured[0].path).to.equal('/channels/' + encodeURIComponent('namespace:channel') + '/messages'); + }); + + /** + * RSL2 - URL encoding with slash in channel name + */ + it('RSL2 - URL encoding with slash', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('path/to/channel').history(null); + + expect(captured).to.have.length(1); + expect(captured[0].path).to.equal('/channels/' + encodeURIComponent('path/to/channel') + '/messages'); + }); +}); diff --git a/test/uts/rest/channel/idempotency.test.ts b/test/uts/rest/channel/idempotency.test.ts new file mode 100644 index 0000000000..2382100bb7 --- /dev/null +++ b/test/uts/rest/channel/idempotency.test.ts @@ -0,0 +1,313 @@ +/** + * UTS: REST Channel Idempotent Publishing Tests + * + * Spec points: RSL1k, RSL1k1, RSL1k2, RSL1k3 + * Source: uts/test/rest/unit/channel/idempotency.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +const Message = Ably.Rest.Message; + +describe('uts/rest/channel/idempotency', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL1k1 - idempotentRestPublishing defaults to true + * + * The idempotentRestPublishing option must default to true. + */ + it('RSL1k1 - idempotentRestPublishing defaults to true', function () { + const client = new Ably.Rest({ key: 'a.b:c' }); + expect(client.options.idempotentRestPublishing).to.equal(true); + }); + + /** + * RSL1k2 - message ID format + * + * When idempotentRestPublishing is true, a published message without + * a client-supplied ID must get a library-generated ID in the format + * :, where is at least 12 characters of + * URL-safe base64 and starts at 0. + */ + it('RSL1k2 - message ID format', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + await ch.publish('event', 'data'); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + + const id = body[0].id; + expect(id).to.be.a('string'); + + const parts = id.split(':'); + expect(parts).to.have.length(2); + + // Base part must be base64 and at least 12 chars + expect(parts[0]).to.match(/^[A-Za-z0-9+/=_-]+$/); + expect(parts[0].length).to.be.at.least(12); + + // Serial starts at 0 + expect(parts[1]).to.equal('0'); + }); + + /** + * RSL1k2 - batch serial increments + * + * When publishing an array of messages, each message must share the + * same base ID but have incrementing serial numbers starting from 0. + */ + it('RSL1k2 - batch serial increments', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1', 's2', 's3'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + await ch.publish([ + { name: 'a', data: 'one' }, + { name: 'b', data: 'two' }, + { name: 'c', data: 'three' }, + ]); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(3); + + // All must have the same base ID + const base0 = body[0].id.split(':')[0]; + const base1 = body[1].id.split(':')[0]; + const base2 = body[2].id.split(':')[0]; + expect(base0).to.equal(base1); + expect(base1).to.equal(base2); + + // Serials must be 0, 1, 2 + expect(body[0].id.split(':')[1]).to.equal('0'); + expect(body[1].id.split(':')[1]).to.equal('1'); + expect(body[2].id.split(':')[1]).to.equal('2'); + }); + + /** + * RSL1k3 - separate publishes get unique base IDs + * + * Each separate publish call must generate a unique base ID so that + * publishes are independently idempotent. + */ + it('RSL1k3 - separate publishes get unique base IDs', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + await ch.publish('event1', 'data1'); + await ch.publish('event2', 'data2'); + + expect(captured).to.have.length(2); + const body1 = JSON.parse(captured[0].body); + const body2 = JSON.parse(captured[1].body); + + const base1 = body1[0].id.split(':')[0]; + const base2 = body2[0].id.split(':')[0]; + expect(base1).to.not.equal(base2); + }); + + /** + * RSL1k3 - no ID when disabled + * + * When idempotentRestPublishing is false, the library must NOT + * generate message IDs. + */ + it('RSL1k3 - no ID when disabled', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: false, + }); + const ch = client.channels.get('test'); + await ch.publish('event', 'data'); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].id).to.be.undefined; + }); + + /** + * RSL1k - client-supplied ID preserved + * + * When a message is published with a client-supplied ID, the library + * must preserve it and not overwrite it with a generated ID. + */ + it('RSL1k - client-supplied ID preserved', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + + const msg = Message.fromValues({ id: 'my-custom-id', name: 'e', data: 'd' }); + await ch.publish(msg); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].id).to.equal('my-custom-id'); + }); + + /** + * RSL1k2 - same ID on retry + * + * When a publish request fails with a 500 error and is retried, the + * retry must use the same message ID to ensure idempotency. + * If ably-js does not retry on 500, we verify the ID format on the + * single request. + */ + it('RSL1k2 - same ID on retry', async function () { + const captured: any[] = []; + let requestCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + captured.push(req); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Internal Server Error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(201, { serials: ['s1'] }); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + + // Spec (RSL1k2): publish MUST retry on 500 with the same idempotent ID. + await ch.publish('event', 'data'); + + expect(captured).to.have.length(2); + + const body1 = JSON.parse(captured[0].body); + const body2 = JSON.parse(captured[1].body); + expect(body1[0].id).to.be.a('string'); + expect(body1[0].id).to.match(/^[A-Za-z0-9+/_-]+:0$/); + expect(body2[0].id).to.equal(body1[0].id); + }); + + /** + * RSL1k - mixed client and library IDs skips generation + * + * When a batch of messages contains any message with a client-supplied + * ID, ably-js skips ID generation for the entire batch (allEmptyIds + * check). Client-supplied IDs are preserved; messages without IDs + * remain without IDs. + */ + it('RSL1k - mixed client and library IDs skips generation', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1', 's2', 's3'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + const ch = client.channels.get('test'); + + const msg1 = Message.fromValues({ id: 'client-id-1', name: 'e1', data: 'd1' }); + const msg2 = Message.fromValues({ name: 'e2', data: 'd2' }); + const msg3 = Message.fromValues({ id: 'client-id-2', name: 'e3', data: 'd3' }); + + await ch.publish([msg1, msg2, msg3]); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(3); + + // First message: client-supplied ID preserved + expect(body[0].id).to.equal('client-id-1'); + + // Second message: no ID generated (allEmptyIds returned false) + expect(body[1].id).to.be.undefined; + + // Third message: client-supplied ID preserved + expect(body[2].id).to.equal('client-id-2'); + }); +}); diff --git a/test/uts/rest/channel/message_versions.test.ts b/test/uts/rest/channel/message_versions.test.ts new file mode 100644 index 0000000000..2d6116559f --- /dev/null +++ b/test/uts/rest/channel/message_versions.test.ts @@ -0,0 +1,129 @@ +/** + * UTS: REST Channel getMessageVersions Tests + * + * Spec points: RSL14a, RSL14b, RSL14c + * Source: uts/test/rest/unit/channel/message_versions.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/channel/getMessageVersions', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL14b - GET to correct path + * + * getMessageVersions(serial) must send a GET request to + * /channels/{channelName}/messages/{serial}/versions. + */ + it('RSL14b - GET to correct path', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + name: 'evt', + data: 'updated-data', + serial: 'msg-serial-1', + action: 1, + version: { serial: 'vs2', timestamp: 1700000002000, clientId: 'user-1', description: 'edit' }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.getMessageVersions('msg-serial-1'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/channels/test/messages/msg-serial-1/versions'); + }); + + /** + * RSL14c - returns PaginatedResult of Messages + * + * getMessageVersions must return a PaginatedResult containing + * Message objects with version fields properly decoded. + */ + it('RSL14c - returns PaginatedResult of Messages', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + name: 'evt', + data: 'updated-data', + serial: 'msg-serial-1', + action: 1, + version: { serial: 'vs2', timestamp: 1700000002000, clientId: 'user-1', description: 'edit' }, + }, + { + name: 'evt', + data: 'original-data', + serial: 'msg-serial-1', + action: 0, + version: { serial: 'vs1', timestamp: 1700000001000 }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + const result = await ch.getMessageVersions('msg-serial-1'); + + expect(result.items).to.have.length(2); + + // First item: updated version with full version fields + expect(result.items[0].data).to.equal('updated-data'); + expect(result.items[0].action).to.equal('message.update'); + expect(result.items[0].version).to.be.an('object'); + expect(result.items[0].version!.serial).to.equal('vs2'); + expect(result.items[0].version!.timestamp).to.equal(1700000002000); + expect(result.items[0].version!.clientId).to.equal('user-1'); + expect(result.items[0].version!.description).to.equal('edit'); + + // Second item: original version with minimal version fields + expect(result.items[1].data).to.equal('original-data'); + expect(result.items[1].action).to.equal('message.create'); + expect(result.items[1].version).to.be.an('object'); + expect(result.items[1].version!.serial).to.equal('vs1'); + expect(result.items[1].version!.timestamp).to.equal(1700000001000); + }); + + /** + * RSL14a - params as querystring + * + * Additional params passed to getMessageVersions must be included + * as query string parameters on the request. + */ + it('RSL14a - params as querystring', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.getMessageVersions('msg-serial-1', { direction: 'backwards', limit: '10' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('direction')).to.equal('backwards'); + expect(captured[0].url.searchParams.get('limit')).to.equal('10'); + }); +}); diff --git a/test/uts/rest/channel/publish.test.ts b/test/uts/rest/channel/publish.test.ts new file mode 100644 index 0000000000..a489ff93c4 --- /dev/null +++ b/test/uts/rest/channel/publish.test.ts @@ -0,0 +1,460 @@ +/** + * UTS: REST Channel Publish Tests + * + * Spec points: RSL1a, RSL1b, RSL1c, RSL1e, RSL1h, RSL1i, RSL1j, RSL1m1, RSL1m2, RSL1m3 + * Source: uts/test/rest/unit/channel/publish.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +const Message = Ably.Rest.Message; + +describe('uts/rest/channel/publish', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL1a - publish sends POST to correct path + * + * Publishing a message on a channel must send a POST request + * to /channels//messages. + */ + it('RSL1a - publish sends POST to correct path', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.publish('event', 'data'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('post'); + expect(captured[0].path).to.equal('/channels/test/messages'); + }); + + /** + * RSL1b - publish body contains message + * + * The POST body must contain the published message serialized as JSON. + */ + it('RSL1b - publish body contains message', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.publish('event', 'data'); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + // ably-js sends an array of messages + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].name).to.equal('event'); + expect(body[0].data).to.equal('data'); + }); + + /** + * RSL1c - publish array sends single request + * + * Publishing an array of messages must send them all in a single + * POST request, with the body containing all messages. + */ + it('RSL1c - publish array sends single request', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.publish([ + { name: 'a', data: 'one' }, + { name: 'b', data: 'two' }, + { name: 'c', data: 'three' }, + ]); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(3); + expect(body[0].name).to.equal('a'); + expect(body[1].name).to.equal('b'); + expect(body[2].name).to.equal('c'); + }); + + /** + * RSL1e - null name omitted from body + * + * Per spec: "If any of the values are null, then key is not sent to Ably" + */ + it('RSL1e - null name omitted from body', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.publish(null, 'data'); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect('name' in body[0]).to.be.false; + expect(body[0].data).to.equal('data'); + }); + + /** + * RSL1e - null data omitted from body + * + * Per spec: "If any of the values are null, then key is not sent to Ably" + */ + it('RSL1e - null data omitted from body', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.publish('event', null); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].name).to.equal('event'); + expect('data' in body[0]).to.be.false; + }); + + /** + * RSL1h - publish(name, data) two-arg form + * + * The two-argument publish(name, data) form must produce a message + * with both name and data fields in the request body. + */ + it('RSL1h - publish(name, data) two-arg form', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + await ch.publish('my-event', 'my-data'); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].name).to.equal('my-event'); + expect(body[0].data).to.equal('my-data'); + }); + + /** + * RSL1i - message size limit exceeded + * + * When the total message size exceeds maxMessageSize, the publish must + * fail with error code 40009 without sending a request. Uses explicit + * maxMessageSize for deterministic testing. + */ + it('RSL1i - message size limit exceeded', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + // Use explicit maxMessageSize for deterministic testing + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + maxMessageSize: 1024, + }); + const ch = client.channels.get('test'); + + // Data that exceeds the 1024 limit + const largeData = 'x'.repeat(2000); + + try { + await ch.publish('event', largeData); + expect.fail('Expected publish to throw due to message size limit'); + } catch (error: any) { + expect(error.code).to.equal(40009); + } + + // No HTTP request should have been made + expect(captured).to.have.length(0); + }); + + /** + * RSL1i - message at size limit succeeds + * + * A message at or under the size limit should succeed. + */ + it('RSL1i - message at size limit succeeds', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + maxMessageSize: 65536, + }); + const ch = client.channels.get('test'); + + // Small data well within the limit + await ch.publish('event', 'small data'); + + expect(captured).to.have.length(1); + }); + + /** + * RSL1j - all message attributes transmitted + * + * When a message is constructed with all optional attributes + * (id, clientId, extras), they must all appear in the request body. + */ + it('RSL1j - all message attributes transmitted', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + + const msg = Message.fromValues({ + name: 'e', + data: 'd', + id: 'msg-1', + clientId: 'c1', + extras: { push: { notification: { title: 'Hi' } } }, + }); + + await ch.publish(msg); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].name).to.equal('e'); + expect(body[0].data).to.equal('d'); + expect(body[0].id).to.equal('msg-1'); + expect(body[0].clientId).to.equal('c1'); + expect(body[0].extras).to.deep.equal({ push: { notification: { title: 'Hi' } } }); + }); + + /** + * RSL1m1 - library clientId not auto-injected + * + * When a client has a clientId set in options but the published message + * does not specify a clientId, the library must NOT auto-inject the + * clientId into the message body (ably-js behaviour for REST). + */ + it('RSL1m1 - library clientId not auto-injected', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + clientId: 'lib-client', + }); + const ch = client.channels.get('test'); + await ch.publish('event', 'data'); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0]).to.not.have.property('clientId'); + }); + + /** + * RSL1m2 - explicit matching clientId preserved + * + * When a client has a clientId and the message explicitly sets the + * same clientId, it must be preserved in the request body. + */ + it('RSL1m2 - explicit matching clientId preserved', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + clientId: 'lib-client', + }); + const ch = client.channels.get('test'); + + const msg = Message.fromValues({ name: 'event', data: 'data', clientId: 'lib-client' }); + await ch.publish(msg); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].clientId).to.equal('lib-client'); + }); + + /** + * RSL1m3 - unidentified client with message clientId + * + * When a client has no clientId set but the message explicitly sets + * a clientId, it must be preserved in the request body. + */ + it('RSL1m3 - unidentified client with message clientId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + + const msg = Message.fromValues({ name: 'event', data: 'data', clientId: 'msg-client' }); + await ch.publish(msg); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + expect(body[0].clientId).to.equal('msg-client'); + }); + + /** + * RSL1e - Both name and data null + * + * Publishing with both name and data null should succeed. + * The wire body should contain an empty message object (or one with + * null fields). + */ + it('RSL1e - both name and data null', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish(null, null); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array'); + expect(body).to.have.length(1); + // The message should be essentially empty (name and data are null/missing) + }); + + /** + * RSL1l - Publish params passed as querystring + * + * Additional params passed to publish should appear as query parameters. + */ + it('RSL1l - publish params as querystring', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test'); + + const msg = Message.fromValues({ name: 'event', data: 'data' }); + await ch.publish(msg, { customParam: 'customValue', anotherParam: '123' } as any); + + expect(captured).to.have.length(1); + // Spec (RSL1l): additional params MUST appear as query parameters. + // DEVIATION: ably-js RestChannel.publish() may not support additional params. See deviations.md. + expect(captured[0].url.searchParams.get('customParam')).to.equal('customValue'); + expect(captured[0].url.searchParams.get('anotherParam')).to.equal('123'); + }); +}); diff --git a/test/uts/rest/channel/publish_result.test.ts b/test/uts/rest/channel/publish_result.test.ts new file mode 100644 index 0000000000..c5c7cc869a --- /dev/null +++ b/test/uts/rest/channel/publish_result.test.ts @@ -0,0 +1,107 @@ +/** + * UTS: REST Channel Publish Result Tests + * + * Spec points: RSL1n + * Source: uts/test/rest/unit/channel/publish_result.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/channel/publish_result', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL1n - single message returns PublishResult with serial + * + * When a single message is published, the server responds with a + * PublishResult containing a serials array with one entry. + */ + it('RSL1n - single message returns PublishResult with serial', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['serial-abc'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + const result = await ch.publish('event', 'data'); + + expect(captured).to.have.length(1); + expect(result).to.have.property('serials'); + expect(result.serials).to.have.length(1); + expect(result.serials[0]).to.equal('serial-abc'); + }); + + /** + * RSL1n - batch returns PublishResult with multiple serials + * + * When multiple messages are published in a single call, the server + * responds with a serials array containing one entry per message. + */ + it('RSL1n - batch returns PublishResult with multiple serials', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1', 's2', 's3'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + const result = await ch.publish([ + { name: 'event1', data: 'data1' }, + { name: 'event2', data: 'data2' }, + { name: 'event3', data: 'data3' }, + ]); + + expect(captured).to.have.length(1); + expect(result).to.have.property('serials'); + expect(result.serials).to.have.length(3); + expect(result.serials[0]).to.equal('s1'); + expect(result.serials[1]).to.equal('s2'); + expect(result.serials[2]).to.equal('s3'); + }); + + /** + * RSL1n - null serial preserved (conflated) + * + * When the server conflates messages, it may return null for some + * serials entries. The client must preserve these null values. + */ + it('RSL1n - null serial preserved (conflated)', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: [null, 's2'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + const result = await ch.publish([ + { name: 'event1', data: 'data1' }, + { name: 'event2', data: 'data2' }, + ]); + + expect(captured).to.have.length(1); + expect(result).to.have.property('serials'); + expect(result.serials).to.have.length(2); + expect(result.serials[0]).to.equal(null); + expect(result.serials[1]).to.equal('s2'); + }); +}); diff --git a/test/uts/rest/channel/rest_channel_attributes.test.ts b/test/uts/rest/channel/rest_channel_attributes.test.ts new file mode 100644 index 0000000000..899a61616d --- /dev/null +++ b/test/uts/rest/channel/rest_channel_attributes.test.ts @@ -0,0 +1,146 @@ +/** + * UTS: REST Channel Attributes Tests + * + * Spec points: RSL7, RSL8, RSL8a, RSL9 + * Source: uts/test/rest/unit/channel/rest_channel_attributes.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/channel/rest_channel_attributes', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL9 - channel name attribute + * + * The channel object must expose its name via a name attribute, + * including any namespace prefix. + */ + it('RSL9 - channel name attribute', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + const ch1 = client.channels.get('my-channel'); + expect(ch1.name).to.equal('my-channel'); + + const ch2 = client.channels.get('namespace:channel-name'); + expect(ch2.name).to.equal('namespace:channel-name'); + }); + + /** + * RSL7 - setOptions completes without error + * + * Calling setOptions with an empty options object must complete + * successfully without throwing. + */ + it('RSL7 - setOptions completes without error', async function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test-channel'); + + await channel.setOptions({}); + }); + + /** + * RSL8 - status sends GET to correct path + * + * Calling status() on a channel sends a GET request to + * /channels/. + */ + it('RSL8 - status sends GET to correct path', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channelId: 'test-channel', + status: { + isActive: true, + occupancy: { metrics: { connections: 5 } }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.status(); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/channels/test-channel'); + }); + + /** + * RSL8 - status URL encodes channel name + * + * Channel names containing special characters (colons, spaces, etc.) + * must be URL-encoded in the request path. + */ + it('RSL8 - status URL encodes channel name', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channelId: 'namespace:my channel', + status: { + isActive: true, + occupancy: { metrics: { connections: 1 } }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('namespace:my channel'); + await ch.status(); + + expect(captured).to.have.length(1); + expect(captured[0].path).to.contain(encodeURIComponent('namespace:my channel')); + }); + + /** + * RSL8a - status returns ChannelDetails + * + * The status() method returns a ChannelDetails object with channelId, + * status.isActive, and status.occupancy.metrics fields. + */ + it('RSL8a - status returns ChannelDetails', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + channelId: 'test-RSL8a', + status: { + isActive: true, + occupancy: { + metrics: { + connections: 5, + publishers: 2, + subscribers: 3, + }, + }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-RSL8a'); + const result = await ch.status(); + + expect(result.channelId).to.equal('test-RSL8a'); + expect(result.status.isActive).to.equal(true); + expect(result.status.occupancy.metrics.connections).to.equal(5); + expect(result.status.occupancy.metrics.publishers).to.equal(2); + expect(result.status.occupancy.metrics.subscribers).to.equal(3); + }); +}); diff --git a/test/uts/rest/channel/update_delete_message.test.ts b/test/uts/rest/channel/update_delete_message.test.ts new file mode 100644 index 0000000000..d9701ee8e4 --- /dev/null +++ b/test/uts/rest/channel/update_delete_message.test.ts @@ -0,0 +1,364 @@ +/** + * UTS: REST Channel Update/Delete/Append Message Tests + * + * Spec points: RSL15a, RSL15b, RSL15b7, RSL15c, RSL15d, RSL15e, RSL15f + * Source: uts/test/rest/unit/channel/update_delete_message.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function msg(fields: any) { + return Ably.Rest.Message.fromValues(fields); +} + +describe('uts/rest/channel/update_delete_message', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL15b - updateMessage sends PATCH + * + * updateMessage must send a PATCH request to /channels//messages/ + * with the message body containing action=1 (MESSAGE_UPDATE). + */ + it('RSL15b - updateMessage sends PATCH', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.updateMessage(msg({ serial: 'msg-serial-1', name: 'updated', data: 'new-data' })); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('patch'); + expect(captured[0].path).to.equal('/channels/test-channel/messages/' + encodeURIComponent('msg-serial-1')); + const body = JSON.parse(captured[0].body); + expect(body.action).to.equal(1); + expect(body.name).to.equal('updated'); + expect(body.data).to.equal('new-data'); + }); + + /** + * RSL15b - deleteMessage sends PATCH with action 2 + * + * deleteMessage must send a PATCH request with action=2 (MESSAGE_DELETE). + */ + it('RSL15b - deleteMessage sends PATCH with action 2', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.deleteMessage(msg({ serial: 'msg-serial-1' })); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('patch'); + expect(captured[0].path).to.equal('/channels/test-channel/messages/' + encodeURIComponent('msg-serial-1')); + const body = JSON.parse(captured[0].body); + expect(body.action).to.equal(2); + }); + + /** + * RSL15b - appendMessage sends PATCH with action 5 + * + * appendMessage must send a PATCH request with action=5 (MESSAGE_APPEND). + */ + it('RSL15b - appendMessage sends PATCH with action 5', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.appendMessage(msg({ serial: 'msg-serial-1', data: 'appended' })); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('patch'); + expect(captured[0].path).to.equal('/channels/test-channel/messages/' + encodeURIComponent('msg-serial-1')); + const body = JSON.parse(captured[0].body); + expect(body.action).to.equal(5); + }); + + /** + * RSL15b7 - version set with MessageOperation + * + * When an operation object is provided, the serialized body must include + * a version field with clientId, description, and metadata from the operation. + */ + it('RSL15b7 - version set with MessageOperation', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.updateMessage(msg({ serial: 's1', data: 'updated' }), { + clientId: 'user1', + description: 'fixed typo', + metadata: { reason: 'typo' }, + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.version).to.be.an('object'); + expect(body.version.clientId).to.equal('user1'); + expect(body.version.description).to.equal('fixed typo'); + expect(body.version.metadata).to.deep.equal({ reason: 'typo' }); + }); + + /** + * RSL15b7 - version absent without operation + * + * When no operation object is provided, the serialized body must not + * include a version field. + */ + it('RSL15b7 - version absent without operation', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.updateMessage(msg({ serial: 's1', data: 'updated' })); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.version).to.be.undefined; + }); + + /** + * RSL15c - does not mutate user-supplied message + * + * The update/delete methods must not modify the original message object + * passed in by the user. + */ + it('RSL15c - does not mutate user-supplied message', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + + const original = msg({ serial: 's1', name: 'original', data: 'original-data' }); + await ch.updateMessage(original); + + // The original message must not have been mutated with an action field + expect(original.action).to.be.undefined; + expect(original.name).to.equal('original'); + expect(original.data).to.equal('original-data'); + }); + + /** + * RSL15e - returns UpdateDeleteResult with versionSerial + * + * The resolved value must contain the versionSerial from the server response. + */ + it('RSL15e - returns UpdateDeleteResult with versionSerial', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'version-serial-abc' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + const result = await ch.updateMessage(msg({ serial: 's1', data: 'd' })); + + expect(result.versionSerial).to.equal('version-serial-abc'); + }); + + /** + * RSL15e - null versionSerial preserved + * + * When the server returns null for versionSerial, the client must + * preserve it as null rather than converting to undefined. + */ + it('RSL15e - null versionSerial preserved', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: null }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + const result = await ch.updateMessage(msg({ serial: 's1', data: 'd' })); + + expect(result.versionSerial).to.equal(null); + }); + + /** + * RSL15f - params sent as querystring + * + * When params are provided, they must be sent as URL query parameters. + */ + it('RSL15f - params sent as querystring', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.updateMessage(msg({ serial: 's1', data: 'd' }), undefined, { key: 'value', num: '42' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('key')).to.equal('value'); + expect(captured[0].url.searchParams.get('num')).to.equal('42'); + }); + + /** + * RSL15a - serial required + * + * If the message lacks a serial, updateMessage, deleteMessage, and + * appendMessage must all throw an error with code 40003. + */ + it('RSL15a - serial required', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + + // updateMessage should throw + try { + await ch.updateMessage(msg({ name: 'x', data: 'y' })); + expect.fail('Expected updateMessage to throw'); + } catch (error: any) { + expect(error.code).to.equal(40003); + } + + // deleteMessage should throw + try { + await ch.deleteMessage(msg({ name: 'x', data: 'y' })); + expect.fail('Expected deleteMessage to throw'); + } catch (error: any) { + expect(error.code).to.equal(40003); + } + + // appendMessage should throw + try { + await ch.appendMessage(msg({ name: 'x', data: 'y' })); + expect.fail('Expected appendMessage to throw'); + } catch (error: any) { + expect(error.code).to.equal(40003); + } + + // No requests should have been made + expect(captured).to.have.length(0); + }); + + /** + * RSL15d - data encoded per RSL4 + * + * Object data must be JSON-encoded with an encoding field set to 'json'. + */ + it('RSL15d - data encoded per RSL4', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.updateMessage(msg({ serial: 's1', data: { key: 'value' } })); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(typeof body.data).to.equal('string'); + expect(body.encoding).to.equal('json'); + }); + + /** + * RSL15b - serial URL-encoded + * + * The serial must be URL-encoded in the request path to handle + * special characters correctly. + */ + it('RSL15b - serial URL-encoded', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { versionSerial: 'vs1' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.updateMessage(msg({ serial: 'serial/special:chars', data: 'd' })); + + expect(captured).to.have.length(1); + // The path should contain the URL-encoded serial + expect(captured[0].path).to.include(encodeURIComponent('serial/special:chars')); + }); +}); diff --git a/test/uts/rest/channels_collection.test.ts b/test/uts/rest/channels_collection.test.ts new file mode 100644 index 0000000000..96d922ca85 --- /dev/null +++ b/test/uts/rest/channels_collection.test.ts @@ -0,0 +1,178 @@ +/** + * UTS: REST Channels Collection Tests + * + * Spec points: RSN1, RSN2, RSN3a, RSN3b, RSN3c, RSN4a, RSN4b + * Source: uts/test/rest/unit/channels_collection.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/channels_collection', function () { + let mock; + + beforeEach(function () { + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); + installMockHttp(mock); + }); + + afterEach(function () { + restoreAll(); + }); + + /** + * RSN1 - Channels collection accessible via RestClient + * + * The RestClient exposes a channels collection with a get() method + * for obtaining RestChannel instances. + */ + it('RSN1 - Channels collection accessible via RestClient', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + expect(client.channels).to.exist; + expect(client.channels.get).to.be.a('function'); + }); + + /** + * RSN2 - Check channel existence + * + * Before a channel is created, it should not appear in the collection. + * After get() is called, it should be present. + */ + it('RSN2 - Check channel existence', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + // Before creating any channel + expect('test' in client.channels.all).to.be.false; + + // Create the channel via get + client.channels.get('test'); + + // After creating the channel + expect('test' in client.channels.all).to.be.true; + + // Non-existent channel should not be present + expect('other' in client.channels.all).to.be.false; + }); + + /** + * RSN2 - Iterate through existing channels + * + * Multiple channels created via get() should all be iterable + * through the channels.all property. + */ + it('RSN2 - Iterate through existing channels', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + client.channels.get('channel-a'); + client.channels.get('channel-b'); + client.channels.get('channel-c'); + + const channelNames = Object.keys(client.channels.all); + + expect(channelNames).to.have.length(3); + expect(channelNames).to.include('channel-a'); + expect(channelNames).to.include('channel-b'); + expect(channelNames).to.include('channel-c'); + }); + + /** + * RSN3a - Get creates new channel if none exists + * + * Calling get() with a channel name that does not yet exist + * creates a new RestChannel with the specified name. + */ + it('RSN3a - Get creates new channel if none exists', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const channel = client.channels.get('test'); + + expect(channel).to.exist; + expect(channel.name).to.equal('test'); + expect('test' in client.channels.all).to.be.true; + }); + + /** + * RSN3a - Get returns same instance for existing channel + * + * Calling get() with the same channel name returns the same + * cached RestChannel instance (identity equality). + */ + it('RSN3a - Get returns same instance for existing channel', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const channel1 = client.channels.get('test'); + const channel2 = client.channels.get('test'); + + expect(channel1).to.equal(channel2); + }); + + /** + * RSN4a - Release removes channel from collection + * + * Calling release() with a channel name removes that channel + * from the internal cache, so it no longer appears in all. + */ + it('RSN4a - Release removes channel from collection', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + client.channels.get('test'); + expect('test' in client.channels.all).to.be.true; + + client.channels.release('test'); + expect('test' in client.channels.all).to.be.false; + }); + + /** + * RSN4b - Release on non-existent channel is no-op + * + * Calling release() with a channel name that does not correspond + * to an existing channel must return without error. + */ + it('RSN4b - Release on non-existent channel is no-op', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + // Should not throw + expect(() => client.channels.release('nonexistent')).to.not.throw(); + + // Collection should still be empty + expect(Object.keys(client.channels.all)).to.have.length(0); + }); + + /** + * RSN3a - Get after release creates new instance + * + * After releasing a channel and calling get() again with the same name, + * a new RestChannel instance is created (not the previously cached one). + */ + it('RSN3a - Get after release creates new instance', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const channel1 = client.channels.get('test'); + client.channels.release('test'); + const channel2 = client.channels.get('test'); + + expect(channel1).to.not.equal(channel2); + expect(channel2.name).to.equal('test'); + expect('test' in client.channels.all).to.be.true; + }); + + /** + * RSN3c - Get with channelOptions updates options on channel + * + * When get() is called with channelOptions, those options are applied + * to the channel (either new or existing). + */ + it('RSN3c - Get with channelOptions updates options', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const channel = client.channels.get('test', { params: { rewind: '1' } }); + + expect(channel.name).to.equal('test'); + expect(channel.channelOptions).to.deep.include({ params: { rewind: '1' } }); + }); +}); diff --git a/test/uts/rest/encoding/message_encoding.test.ts b/test/uts/rest/encoding/message_encoding.test.ts new file mode 100644 index 0000000000..1c8b4e4e7b --- /dev/null +++ b/test/uts/rest/encoding/message_encoding.test.ts @@ -0,0 +1,367 @@ +/** + * UTS: Message Encoding Tests + * + * Spec points: RSL4, RSL4a, RSL4b, RSL4c, RSL4d, RSL6, RSL6a, RSL6b + * Source: uts/test/rest/unit/encoding/message_encoding.md + * + * Skipped: + * - Msgpack-specific tests (RSL4c msgpack, RSL6 msgpack bin/str) — mock doesn't support msgpack responses + * - Encoding fixtures from ably-common — separate fixture-based tests + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +function publishMock() { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + return { mock, captured }; +} + +function historyMock(messages: any) { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, messages); + }, + }); + return mock; +} + +describe('uts/rest/encoding/message_encoding', function () { + afterEach(function () { + restoreAll(); + }); + + // ── Encoding (RSL4) ────────────────────────────────────────────── + + /** + * RSL4a - String data transmitted without encoding + */ + it('RSL4a - string data has no encoding', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', 'plain string data'); + + const body = JSON.parse(captured[0].body); + expect(body[0].data).to.equal('plain string data'); + expect(body[0].encoding).to.satisfy((v: any) => v === undefined || v === null); + }); + + /** + * RSL4b - JSON object serialized with encoding: "json" + */ + it('RSL4b - object data JSON-encoded', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', { key: 'value', nested: { a: 1 } }); + + const body = JSON.parse(captured[0].body); + expect(body[0].encoding).to.equal('json'); + expect(typeof body[0].data).to.equal('string'); + expect(JSON.parse(body[0].data)).to.deep.equal({ key: 'value', nested: { a: 1 } }); + }); + + /** + * RSL4c - Binary data base64-encoded with JSON protocol + */ + it('RSL4c - binary data base64-encoded for JSON protocol', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]); + await client.channels.get('test').publish('event', binaryData); + + const body = JSON.parse(captured[0].body); + expect(body[0].encoding).to.equal('base64'); + const decoded = Buffer.from(body[0].data, 'base64'); + expect(Buffer.compare(decoded, binaryData)).to.equal(0); + }); + + /** + * RSL4d - Array data JSON-encoded + */ + it('RSL4d - array data JSON-encoded', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', [1, 2, 'three', { four: 4 }]); + + const body = JSON.parse(captured[0].body); + expect(body[0].encoding).to.equal('json'); + expect(JSON.parse(body[0].data)).to.deep.equal([1, 2, 'three', { four: 4 }]); + }); + + /** + * RSL4 - Null data transmitted without encoding + */ + it('RSL4 - null data has no encoding', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', null); + + const body = JSON.parse(captured[0].body); + expect(body[0].data).to.satisfy((v: any) => v === undefined || v === null); + expect(body[0].encoding).to.satisfy((v: any) => v === undefined || v === null); + }); + + /** + * RSL4 - Empty string transmitted without encoding + */ + it('RSL4 - empty string has no encoding', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', ''); + + const body = JSON.parse(captured[0].body); + expect(body[0].data).to.equal(''); + expect(body[0].encoding).to.satisfy((v: any) => v === undefined || v === null); + }); + + /** + * RSL4 - Empty array JSON-encoded + */ + it('RSL4 - empty array JSON-encoded', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', []); + + const body = JSON.parse(captured[0].body); + expect(body[0].encoding).to.equal('json'); + expect(JSON.parse(body[0].data)).to.deep.equal([]); + }); + + /** + * RSL4 - Empty object JSON-encoded + */ + it('RSL4 - empty object JSON-encoded', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', {}); + + const body = JSON.parse(captured[0].body); + expect(body[0].encoding).to.equal('json'); + expect(JSON.parse(body[0].data)).to.deep.equal({}); + }); + + /** + * RSL4 - JSON protocol uses application/json content-type + */ + it('RSL4 - JSON protocol content-type', async function () { + const { mock, captured } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('event', 'test'); + + expect(captured[0].headers['content-type']).to.include('application/json'); + expect(captured[0].headers['accept']).to.include('application/json'); + }); + + // ── Decoding (RSL6) ────────────────────────────────────────────── + + /** + * RSL6a - Decode base64 data to binary + */ + it('RSL6a - base64 decoded to Buffer', async function () { + installMockHttp( + historyMock([{ id: 'msg1', name: 'event', data: 'AAECAwQ=', encoding: 'base64', timestamp: 1234567890000 }]), + ); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + expect(Buffer.isBuffer(result.items[0].data)).to.be.true; + expect(Buffer.compare(result.items[0].data, Buffer.from([0, 1, 2, 3, 4]))).to.equal(0); + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSL6a - Decode JSON string to native object + */ + it('RSL6a - json decoded to object', async function () { + installMockHttp( + historyMock([ + { id: 'msg1', name: 'event', data: '{"key":"value","number":42}', encoding: 'json', timestamp: 1234567890000 }, + ]), + ); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + expect(result.items[0].data).to.deep.equal({ key: 'value', number: 42 }); + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSL6a - Chained encoding json/base64 decoded in reverse order + */ + it('RSL6a - chained json/base64 decoded', async function () { + // {"key":"value"} → base64 = eyJrZXkiOiJ2YWx1ZSJ9 + const base64OfJson = Buffer.from('{"key":"value"}').toString('base64'); + + installMockHttp( + historyMock([ + { id: 'msg1', name: 'event', data: base64OfJson, encoding: 'json/base64', timestamp: 1234567890000 }, + ]), + ); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + expect(result.items[0].data).to.deep.equal({ key: 'value' }); + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSL6 - utf-8/base64 decoded to string + */ + it('RSL6 - utf-8/base64 decoded to string', async function () { + // "Hello World" → base64 = SGVsbG8gV29ybGQ= + installMockHttp( + historyMock([ + { id: 'msg1', name: 'event', data: 'SGVsbG8gV29ybGQ=', encoding: 'utf-8/base64', timestamp: 1234567890000 }, + ]), + ); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + expect(result.items[0].data).to.equal('Hello World'); + expect(typeof result.items[0].data).to.equal('string'); + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSL6 - Complex chained encoding json/utf-8/base64 + */ + it('RSL6 - json/utf-8/base64 fully decoded', async function () { + const obj = { status: 'active', count: 5 }; + const base64Data = Buffer.from(JSON.stringify(obj)).toString('base64'); + + installMockHttp( + historyMock([ + { id: 'msg1', name: 'event', data: base64Data, encoding: 'json/utf-8/base64', timestamp: 1234567890000 }, + ]), + ); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + expect(result.items[0].data).to.deep.equal({ status: 'active', count: 5 }); + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSL6b - Unrecognized encoding preserved + */ + it('RSL6b - unrecognized encoding preserved', async function () { + // base64 of "encrypted-data" + const base64Data = Buffer.from('encrypted-data').toString('base64'); + + installMockHttp( + historyMock([ + { id: 'msg1', name: 'event', data: base64Data, encoding: 'custom-encryption/base64', timestamp: 1234567890000 }, + ]), + ); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + // base64 should be decoded, but custom-encryption is unrecognized and preserved + expect(result.items[0].encoding).to.equal('custom-encryption'); + // Data is the base64-decoded bytes (not further processed) + expect(Buffer.isBuffer(result.items[0].data)).to.be.true; + }); + + /** + * RSL6a - String data without encoding passes through + */ + it('RSL6a - string data without encoding passes through', async function () { + installMockHttp(historyMock([{ id: 'msg1', name: 'event', data: 'plain text', timestamp: 1234567890000 }])); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.channels.get('test').history(null); + + expect(result.items[0].data).to.equal('plain text'); + expect(typeof result.items[0].data).to.equal('string'); + }); + + /** + * RSL4a - Number data type rejected + * + * Per RSL4a: payloads must be binary, strings, or objects capable of + * JSON representation. Any other data type should result in an error. + */ + it('RSL4a - number data type rejected', async function () { + const { mock } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + try { + await client.channels.get('test').publish('event', 42); + expect.fail('Expected publish to throw'); + } catch (e: any) { + expect(e.code).to.equal(40013); + } + }); + + /** + * RSL4a - Boolean data type rejected + * + * Per RSL4a: payloads must be binary, strings, or objects capable of + * JSON representation. Any other data type should result in an error. + */ + it('RSL4a - boolean data type rejected', async function () { + const { mock } = publishMock(); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + try { + await client.channels.get('test').publish('event', true); + expect.fail('Expected publish to throw'); + } catch (e: any) { + expect(e.code).to.equal(40013); + } + }); + + // --------------------------------------------------------------------------- + // MsgPack tests — PENDING (mock HTTP does not support msgpack encoding) + // --------------------------------------------------------------------------- + + it('RSL4c - binary data with msgpack protocol', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSL6 - msgpack bin type decoded to Buffer', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSL6 - msgpack str type decoded to string', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); +}); diff --git a/test/uts/rest/fallback.test.ts b/test/uts/rest/fallback.test.ts new file mode 100644 index 0000000000..4dc86bf371 --- /dev/null +++ b/test/uts/rest/fallback.test.ts @@ -0,0 +1,948 @@ +/** + * UTS: REST Fallback and Endpoint Configuration Tests + * + * Spec points: RSC15, RSC15a, RSC15f, RSC15l, RSC15l4, RSC15m, + * REC1a, REC1b1, REC1b2, REC1b3, REC1b4, REC1c1, REC1c2, REC1d, REC1d1, + * REC2a2, REC2c2, REC2c3, REC2c4, REC2c6 + * Source: specification/uts/rest/unit/fallback.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, enableFakeTimers, restoreAll } from '../helpers'; + +describe('uts/rest/fallback', function () { + afterEach(function () { + restoreAll(); + }); + + // ── Fallback behavior (RSC15) ────────────────────────────────────── + + /** + * RSC15l - 500 triggers fallback + * + * When the primary host returns a 500 error, the client should retry + * the request on a fallback host. + */ + it('RSC15l - 500 triggers fallback', async function () { + let requestCount = 0; + const hosts: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + expect(hosts[1]).to.not.equal('main.realtime.ably.net'); + expect(hosts[1]).to.match(/^main\.[a-e]\.fallback\.ably-realtime\.com$/); + }); + + /** + * RSC15l - connection refused triggers fallback + * + * When the primary host refuses the connection, the client should + * retry on a fallback host. + */ + it('RSC15l - connection refused triggers fallback', async function () { + let connCount = 0; + const connHosts: any[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => { + connCount++; + connHosts.push(conn.host); + if (connCount === 1) { + conn.respond_with_refused(); + } else { + conn.respond_with_success(); + } + }, + onRequest: (req) => { + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(connCount).to.equal(2); + expect(connHosts[0]).to.equal('main.realtime.ably.net'); + expect(connHosts[1]).to.not.equal('main.realtime.ably.net'); + expect(connHosts[1]).to.match(/^main\.[a-e]\.fallback\.ably-realtime\.com$/); + }); + + /** + * RSC15l - 4xx does NOT trigger fallback + * + * Client errors (4xx) are not retryable. The client should not attempt + * a fallback host and should propagate the error immediately. + */ + it('RSC15l - 4xx does NOT trigger fallback', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(400, { error: { message: 'Bad request', code: 40000, statusCode: 400 } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(400); + } + + expect(requestCount).to.equal(1); + }); + + /** + * RSC15m - no fallback when fallbackHosts is empty + * + * When fallbackHosts is explicitly set to an empty array, the client + * should not attempt any fallback and should fail after the primary host. + */ + it('RSC15m - no fallback when fallbackHosts is empty', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, fallbackHosts: [] }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + } + + expect(requestCount).to.equal(1); + }); + + // ── Endpoint configuration (REC) ────────────────────────────────── + + /** + * REC1a - default primary domain + * + * Without any endpoint configuration, the default primary host should + * be main.realtime.ably.net. + */ + it('REC1a - default primary domain', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('main.realtime.ably.net'); + }); + + /** + * REC1b4 - endpoint as routing policy + * + * When endpoint is a simple name (no dots), it is treated as a routing + * policy and the host becomes {endpoint}.realtime.ably.net. + */ + it('REC1b4 - endpoint as routing policy', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'sandbox' }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('sandbox.realtime.ably.net'); + }); + + /** + * REC1b2 - endpoint as explicit hostname + * + * When endpoint contains dots, it is treated as an explicit hostname. + */ + it('REC1b2 - endpoint as explicit hostname', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + endpoint: 'custom.ably.example.com', + }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('custom.ably.example.com'); + }); + + /** + * REC1d1 - restHost option + * + * The deprecated restHost option sets the REST host directly. + */ + it('REC1d1 - restHost option', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + restHost: 'custom.rest.example.com', + }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('custom.rest.example.com'); + }); + + /** + * REC1c2 - environment option + * + * The deprecated environment option maps to {environment}.realtime.ably.net. + */ + it('REC1c2 - environment option', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + environment: 'sandbox', + }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('sandbox.realtime.ably.net'); + }); + + /** + * REC2a2 - custom fallbackHosts + * + * When fallbackHosts is set to a custom list, the client should use + * those hosts for fallback instead of the defaults. + */ + it('REC2a2 - custom fallbackHosts', async function () { + let requestCount = 0; + const hosts: any[] = []; + const customFallbacks = ['fb1.example.com', 'fb2.example.com']; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + fallbackHosts: customFallbacks, + }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + expect(customFallbacks).to.include(hosts[1]); + }); + + /** + * REC2c6 - custom restHost has no fallbacks + * + * When restHost is set to a custom domain, fallback hosts are not + * available (unless explicitly provided). A 500 should not trigger retry. + */ + it('REC2c6 - custom restHost has no fallbacks', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + restHost: 'custom.example.com', + }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + } + + expect(requestCount).to.equal(1); + }); + + // ── Additional fallback tests ───────────────────────────────────── + + /** + * RSC15a - fallback hosts are randomized + * + * When the primary host fails and the client falls back, the fallback + * hosts should be selected in a randomized order. Over multiple attempts, + * we expect to see more than one distinct fallback host used. + */ + it('RSC15a - fallback hosts are randomized', async function () { + const fallbackHostsUsed: string[] = []; + + for (let i = 0; i < 10; i++) { + let requestCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + if (req.url.hostname === 'main.realtime.ably.net') { + req.respond_with(500, { error: { message: 'fail', code: 50000, statusCode: 500 } }); + } else { + fallbackHostsUsed.push(req.url.hostname); + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + await client.time(); + + restoreAll(); + } + + const uniqueHosts = new Set(fallbackHostsUsed); + expect(uniqueHosts.size).to.be.at.least(2); + }); + + /** + * RSC15l - DNS error triggers fallback + * + * When the primary host fails DNS resolution, the client should + * retry on a fallback host. + */ + it('RSC15l - DNS error triggers fallback', async function () { + const connHosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => { + connHosts.push(conn.host); + if (conn.host === 'main.realtime.ably.net') { + conn.respond_with_dns_error(); + } else { + conn.respond_with_success(); + } + }, + onRequest: (req) => { + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(connHosts).to.have.length(2); + expect(connHosts[0]).to.equal('main.realtime.ably.net'); + expect(connHosts[1]).to.not.equal('main.realtime.ably.net'); + }); + + /** + * RSC15l - timeout triggers fallback + * + * When the primary host connection times out, the client should + * retry on a fallback host. + */ + it('RSC15l - timeout triggers fallback', async function () { + const connHosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => { + connHosts.push(conn.host); + if (conn.host === 'main.realtime.ably.net') { + conn.respond_with_timeout(); + } else { + conn.respond_with_success(); + } + }, + onRequest: (req) => { + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(connHosts).to.have.length(2); + expect(connHosts[0]).to.equal('main.realtime.ably.net'); + expect(connHosts[1]).to.not.equal('main.realtime.ably.net'); + }); + + /** + * RSC15l - 503 triggers fallback + * + * When the primary host returns a 503 Service Unavailable, the client + * should retry on a fallback host. + */ + it('RSC15l - 503 triggers fallback', async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (req.url.hostname === 'main.realtime.ably.net') { + req.respond_with(503, { error: { message: 'Service unavailable', code: 50300, statusCode: 503 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + expect(hosts[1]).to.not.equal('main.realtime.ably.net'); + }); + + /** + * RSC15f - successful fallback host cached + * + * After a successful fallback, subsequent requests should go to the + * cached fallback host instead of the primary host. + */ + it('RSC15f - successful fallback host cached', async function () { + const captured: any[] = []; + let requestCount = 0; + let fallbackHost: string | null = null; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + captured.push(req); + if (req.url.hostname === 'main.realtime.ably.net') { + req.respond_with(500, { error: { message: 'fail', code: 50000, statusCode: 500 } }); + } else { + if (!fallbackHost) fallbackHost = req.url.hostname; + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + + // First request: primary fails, fallback succeeds + await client.time(); + expect(fallbackHost).to.not.be.null; + + // Second request: should go to cached fallback host, not primary + const countBefore = requestCount; + await client.time(); + + // The second request should use the cached fallback host + const secondRequestHost = captured[captured.length - 1].url.hostname; + expect(secondRequestHost).to.equal(fallbackHost); + }); + + // ── Category A: Additional status code variants ─────────────────── + + [501, 502, 504].forEach((statusCode) => { + it(`RSC15l - ${statusCode} triggers fallback`, async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(statusCode, { error: { message: 'Server error', code: statusCode * 100, statusCode } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + expect(hosts[1]).to.not.equal('main.realtime.ably.net'); + }); + }); + + [401, 404].forEach((statusCode) => { + it(`RSC15l - ${statusCode} does NOT trigger fallback`, async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(statusCode, { error: { message: 'Client error', code: statusCode * 100, statusCode } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(statusCode); + } + + expect(requestCount).to.equal(1); + }); + }); + + // ── Category B: Request timeout and CloudFront ──────────────────── + + it('RSC15l - request timeout triggers fallback', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + let connCount = 0; + const connHosts: string[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => { + connCount++; + connHosts.push(conn.host); + conn.respond_with_success(); + }, + onRequest: (req) => { + requestCount++; + if (requestCount === 1) { + req.respond_with_timeout(); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + // Spec: request-level timeout (after connection succeeds) MUST trigger fallback. + // DEVIATION: ably-js may not retry on request timeout. See deviations.md. + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + try { + const result = await client.time(); + expect(result).to.equal(1234567890000); + expect(connCount).to.be.at.least(2); + expect(connHosts[0]).to.equal('main.realtime.ably.net'); + expect(connHosts[1]).to.not.equal('main.realtime.ably.net'); + } catch (e) { + expect.fail('Request timeout should trigger fallback, but ably-js threw: ' + (e as Error).message); + } + }); + + it('RSC15l4 - CloudFront Server header triggers fallback', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + // Spec: CloudFront Server header with status >= 400 should trigger fallback + // DEVIATION: ably-js does not inspect the Server header. See deviations.md. + req.respond_with( + 403, + { error: { message: 'Forbidden', code: 40300, statusCode: 403 } }, + { Server: 'CloudFront' }, + ); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + try { + const result = await client.time(); + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + expect(hosts[1]).to.not.equal('main.realtime.ably.net'); + } catch (e) { + expect.fail( + 'CloudFront 403 with Server header should trigger fallback, but ably-js threw: ' + (e as Error).message, + ); + } + }); + + // ── Category C: Cached fallback expiry ──────────────────────────── + + it('RSC15f - cached fallback expires after fallbackRetryTimeout', async function () { + const clock = enableFakeTimers(); + const hosts: string[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (req.url.hostname === 'main.realtime.ably.net' && requestCount <= 1) { + req.respond_with(500, { error: { message: 'fail', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + fallbackRetryTimeout: 100, + } as any); + + // First request: primary fails → cached fallback used + await client.time(); + expect(hosts.length).to.be.at.least(2); + const fallbackHost = hosts[hosts.length - 1]; + expect(fallbackHost).to.not.equal('main.realtime.ably.net'); + + // Second request within cache window: should go to cached fallback + hosts.length = 0; + requestCount = 0; + await client.time(); + expect(hosts[0]).to.equal(fallbackHost); + + // Advance time past fallbackRetryTimeout + clock.tick(200); + + // Third request after cache expiry: should try primary again + hosts.length = 0; + requestCount = 0; + await client.time(); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + }); + + // ── Category D: Endpoint edge cases ─────────────────────────────── + + it('REC1b2 - endpoint as localhost', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'localhost' }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('localhost'); + }); + + it('REC1b2 - endpoint as IPv6 address', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + // Spec: endpoint '::1' should be treated as an explicit IPv6 hostname. + // DEVIATION: ably-js constructs an invalid URI (no brackets around IPv6). See deviations.md. + try { + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: '::1' }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.satisfy((h: string) => h === '::1' || h === '[::1]'); + } catch (e) { + expect.fail('IPv6 endpoint should work, but ably-js threw: ' + (e as Error).message); + } + }); + + it('REC1b3 - endpoint as nonprod routing policy', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'nonprod:staging' }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('staging.realtime.ably-nonprod.net'); + }); + + it('REC1d - realtimeHost sets primary domain when restHost not set', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + realtimeHost: 'custom.realtime.example.com', + } as any); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('custom.realtime.example.com'); + }); + + // ── Category E: Option conflict detection ───────────────────────── + + it('REC1b1 - endpoint conflicts with environment', function () { + try { + new Ably.Rest({ key: 'app.key:secret', endpoint: 'sandbox', environment: 'production' } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.equal(40106); + } + }); + + it('REC1b1 - endpoint conflicts with restHost', function () { + try { + new Ably.Rest({ key: 'app.key:secret', endpoint: 'sandbox', restHost: 'custom.host.com' } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.equal(40106); + } + }); + + it('REC1c1 - environment conflicts with restHost', function () { + try { + new Ably.Rest({ key: 'app.key:secret', environment: 'sandbox', restHost: 'custom.host.com' } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.equal(40106); + } + }); + + it('REC1c1 - environment conflicts with realtimeHost', function () { + try { + new Ably.Rest({ key: 'app.key:secret', environment: 'sandbox', realtimeHost: 'custom.rt.com' } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.equal(40106); + } + }); + + it('REC1d - restHost takes precedence over realtimeHost', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + restHost: 'rest.example.com', + realtimeHost: 'realtime.example.com', + } as any); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('rest.example.com'); + }); + + // ── Category F: Fallback domain configuration ───────────────────── + + it('REC2c2 - explicit hostname endpoint has no fallbacks', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + endpoint: 'custom.ably.example.com', + }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + } + + expect(requestCount).to.equal(1); + }); + + it('REC2c3 - nonprod endpoint gets nonprod fallback domains', async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'nonprod:staging' }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('staging.realtime.ably-nonprod.net'); + expect(hosts[1]).to.match(/^staging\.[a-e]\.fallback\.ably-realtime-nonprod\.com$/); + }); + + it('REC2c4 - production routing via endpoint gets production fallback domains', async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'sandbox' }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('sandbox.realtime.ably.net'); + expect(hosts[1]).to.match(/^sandbox\.[a-e]\.fallback\.ably-realtime\.com$/); + }); +}); diff --git a/test/uts/rest/logging.test.ts b/test/uts/rest/logging.test.ts new file mode 100644 index 0000000000..3f84db15ce --- /dev/null +++ b/test/uts/rest/logging.test.ts @@ -0,0 +1,216 @@ +/** + * UTS: REST Logging Tests + * + * Spec points: RSC2, RSC4, TO3b, TO3c + * Source: uts/test/rest/unit/logging.md + * + * ably-js logging API: + * logLevel: 0=NONE, 1=ERROR, 2=MAJOR, 3=MINOR, 4=MICRO + * logHandler: function(msg, level) — receives a pre-formatted string and numeric level + * Default logLevel is 1 (ERROR) + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/logging', function () { + let mock; + + afterEach(function () { + restoreAll(); + }); + + /** + * Helper: create a mock that responds to /time with a valid response. + */ + function setupMock() { + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + } + + /** + * RSC2 - Default log level is error + * + * The default log level in ably-js is ERROR (1). At this level, only + * error-level messages are emitted. Normal client construction and + * time() calls produce MINOR/MICRO messages which should be filtered out. + */ + it('RSC2 - default log level filters non-error messages', async function () { + setupMock(); + + const capturedLogs: any[] = []; + const client = new Ably.Rest({ + key: 'app.key:secret', + logHandler: function (msg, level) { + capturedLogs.push({ msg, level }); + }, + }); + + await client.time(); + + // Default level is ERROR (1). Normal operations produce MINOR (3) + // and MICRO (4) level messages, which should all be filtered out. + // Any messages that do get through must be at ERROR level (1). + for (const log of capturedLogs) { + expect(log.level).to.equal(1, 'Only error-level messages should pass at default log level'); + } + }); + + /** + * TO3b - Log level can be changed to capture more messages + * + * Setting logLevel to MICRO (4) should capture all log events + * including MINOR and MICRO level messages. + */ + it('TO3b - logLevel MICRO captures all messages', async function () { + setupMock(); + + const capturedLogs: any[] = []; + const client = new Ably.Rest({ + key: 'app.key:secret', + logLevel: 4, // MICRO + logHandler: function (msg, level) { + capturedLogs.push({ msg, level }); + }, + }); + + await client.time(); + + // With MICRO level, we should have captured messages + expect(capturedLogs.length).to.be.greaterThan(0); + + // Should have MINOR (3) level messages (e.g. "started; version = ...") + const minorLogs = capturedLogs.filter((l) => l.level === 3); + expect(minorLogs.length).to.be.greaterThan(0, 'Should capture MINOR level messages'); + + // Should have MICRO (4) level messages (e.g. HTTP request details) + const microLogs = capturedLogs.filter((l) => l.level === 4); + expect(microLogs.length).to.be.greaterThan(0, 'Should capture MICRO level messages'); + }); + + /** + * TO3c - Custom logHandler receives messages with level information + * + * A custom logHandler provided via ClientOptions receives a formatted + * string message and a numeric level argument. + */ + it('TO3c - custom logHandler receives messages with level', async function () { + setupMock(); + + const capturedLogs: any[] = []; + const client = new Ably.Rest({ + key: 'app.key:secret', + logLevel: 4, // MICRO — capture everything + logHandler: function (msg, level) { + capturedLogs.push({ msg, level }); + }, + }); + + await client.time(); + + // Handler was called + expect(capturedLogs.length).to.be.greaterThan(0); + + // Each log entry has a string message and numeric level + for (const log of capturedLogs) { + expect(log.msg).to.be.a('string'); + expect(log.level).to.be.a('number'); + expect(log.level).to.be.within(0, 4); + } + + // Messages should be prefixed with "Ably:" + expect(capturedLogs.some((l) => l.msg.startsWith('Ably:'))).to.be.true; + }); + + /** + * RSC4 / RSC2b - logLevel NONE (0) suppresses all log output + * + * Setting logLevel to 0 (NONE) should prevent all log messages + * from reaching the handler. + */ + it('RSC4 - logLevel NONE suppresses all messages', async function () { + setupMock(); + + const capturedLogs: any[] = []; + const client = new Ably.Rest({ + key: 'app.key:secret', + logLevel: 0, // NONE + logHandler: function (msg, level) { + capturedLogs.push({ msg, level }); + }, + }); + + await client.time(); + + // No logs should be captured at all + expect(capturedLogs).to.have.length(0); + }); + + /** + * TO3b - logLevel MINOR (3) captures MINOR but not MICRO + * + * Intermediate log levels should filter correctly: MINOR captures + * levels 1-3 but excludes MICRO (4). + */ + it('TO3b - logLevel MINOR filters MICRO messages', async function () { + setupMock(); + + const capturedLogs: any[] = []; + const client = new Ably.Rest({ + key: 'app.key:secret', + logLevel: 3, // MINOR + logHandler: function (msg, level) { + capturedLogs.push({ msg, level }); + }, + }); + + await client.time(); + + // Should have some messages (MINOR level messages from construction) + expect(capturedLogs.length).to.be.greaterThan(0); + + // No MICRO (4) messages should have passed through + const microLogs = capturedLogs.filter((l) => l.level === 4); + expect(microLogs).to.have.length(0, 'MICRO messages should be filtered at MINOR level'); + + // All captured messages should be at level <= 3 + for (const log of capturedLogs) { + expect(log.level).to.be.at.most(3); + } + }); + + /** + * TO3c2 - Log messages contain HTTP request details + * + * At MICRO level, HTTP operations emit log messages that contain + * request details such as the URL/path being requested. + */ + it('TO3c2 - HTTP request logs contain URL details', async function () { + setupMock(); + + const capturedLogs: any[] = []; + const client = new Ably.Rest({ + key: 'app.key:secret', + logLevel: 4, // MICRO + logHandler: function (msg, level) { + capturedLogs.push({ msg, level }); + }, + }); + + await client.time(); + + // Find HTTP-related log messages + const httpLogs = capturedLogs.filter((l) => l.msg.includes('/time')); + expect(httpLogs.length).to.be.greaterThan(0, 'Should have log messages mentioning /time endpoint'); + + // HTTP request log should mention the path + const requestLog = capturedLogs.find((l) => l.msg.includes('Http') && l.msg.includes('/time')); + expect(requestLog).to.not.be.undefined; + }); +}); diff --git a/test/uts/rest/presence/rest_presence.test.ts b/test/uts/rest/presence/rest_presence.test.ts new file mode 100644 index 0000000000..3764198e99 --- /dev/null +++ b/test/uts/rest/presence/rest_presence.test.ts @@ -0,0 +1,860 @@ +/** + * UTS: REST Presence Tests + * + * Spec points: RSP1, RSP1a, RSP1b, RSP3, RSP3a, RSP3a1, RSP3a2, RSP3a3, + * RSP3b, RSP3c, RSP4, RSP4a, RSP4b1, RSP4b2, RSP4b3, + * RSP5, RSP5a, RSP5b, RSP5e + * Source: uts/test/rest/unit/presence/rest_presence.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/presence/rest_presence', function () { + afterEach(function () { + restoreAll(); + }); + + // --------------------------------------------------------------------------- + // RSP1 - Presence object + // --------------------------------------------------------------------------- + + /** + * RSP1a - presence accessible + * + * channel.presence must exist and be an object. + */ + it('RSP1a - presence accessible on channel', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + expect(channel.presence).to.be.an('object'); + expect(channel.presence).to.not.be.null; + }); + + /** + * RSP1b - same instance + * + * Accessing channel.presence multiple times must return the same instance. + */ + it('RSP1b - channel.presence returns same instance', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + const presence1 = channel.presence; + const presence2 = channel.presence; + expect(presence1).to.equal(presence2); + }); + + // --------------------------------------------------------------------------- + // RSP3 - presence.get() + // --------------------------------------------------------------------------- + + /** + * RSP3a - GET to correct path + * + * presence.get() must send a GET request to /channels/{name}/presence. + */ + it('RSP3a - get() sends GET to /channels/{name}/presence', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test-channel'); + await channel.presence.get({}); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/channels/test-channel/presence'); + }); + + /** + * RSP3b - returns PresenceMessage objects + * + * presence.get() must return a PaginatedResult containing PresenceMessage + * objects with action, clientId, connectionId, data, and timestamp. + */ + it('RSP3b - get() returns PresenceMessage objects', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + action: 1, + clientId: 'user-1', + connectionId: 'conn-abc', + data: 'hello', + timestamp: 1609459200000, + }, + { + action: 1, + clientId: 'user-2', + connectionId: 'conn-def', + data: 'world', + timestamp: 1609459201000, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(2); + + const item0 = result.items[0]; + expect(item0.action).to.equal('present'); + expect(item0.clientId).to.equal('user-1'); + expect(item0.connectionId).to.equal('conn-abc'); + expect(item0.data).to.equal('hello'); + expect(item0.timestamp).to.equal(1609459200000); + + const item1 = result.items[1]; + expect(item1.action).to.equal('present'); + expect(item1.clientId).to.equal('user-2'); + expect(item1.connectionId).to.equal('conn-def'); + expect(item1.data).to.equal('world'); + expect(item1.timestamp).to.equal(1609459201000); + }); + + /** + * RSP3c - empty list + * + * When the server returns an empty array, items.length must be 0. + */ + it('RSP3c - get() with empty response returns empty items', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(0); + expect(result.hasNext()).to.be.false; + expect(result.isLast()).to.be.true; + }); + + /** + * RSP3a1 - limit param + * + * get({limit: 50}) must send limit=50 as a query parameter. + */ + it('RSP3a1 - get() with limit param sends limit query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({ limit: 50 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('50'); + }); + + /** + * RSP3a2 - clientId filter + * + * get({clientId: 'specific'}) must send clientId=specific as a query parameter. + */ + it('RSP3a2 - get() with clientId filter sends clientId query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({ clientId: 'specific' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('clientId')).to.equal('specific'); + }); + + /** + * RSP3a3 - connectionId filter + * + * get({connectionId: 'conn123'}) must send connectionId=conn123 as a query parameter. + */ + it('RSP3a3 - get() with connectionId filter sends connectionId query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({ connectionId: 'conn123' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('connectionId')).to.equal('conn123'); + }); + + // --------------------------------------------------------------------------- + // RSP4 - presence.history() + // --------------------------------------------------------------------------- + + /** + * RSP4a - GET to history path + * + * presence.history() must send a GET request to /channels/{name}/presence/history. + */ + it('RSP4a - history() sends GET to /channels/{name}/presence/history', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test-channel'); + await channel.presence.history({}); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/channels/test-channel/presence/history'); + }); + + /** + * RSP4a - returns PresenceMessage with actions + * + * history() must return PresenceMessage objects with wire actions decoded + * to strings: enter (2), leave (3), update (4). + */ + it('RSP4a - history() returns PresenceMessage with decoded actions', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { action: 2, clientId: 'alice', data: 'joined', timestamp: 1609459200000 }, + { action: 3, clientId: 'bob', data: 'left', timestamp: 1609459201000 }, + { action: 4, clientId: 'carol', data: 'status', timestamp: 1609459202000 }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.history({}); + + expect(result.items).to.have.length(3); + expect(result.items[0].action).to.equal('enter'); + expect(result.items[0].clientId).to.equal('alice'); + expect(result.items[1].action).to.equal('leave'); + expect(result.items[1].clientId).to.equal('bob'); + expect(result.items[2].action).to.equal('update'); + expect(result.items[2].clientId).to.equal('carol'); + }); + + /** + * RSP4b1 - start param + * + * history({start: 1609459200000}) must send start=1609459200000 as a query parameter. + */ + it('RSP4b1 - history() with start param sends start query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ start: 1609459200000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1609459200000'); + }); + + /** + * RSP4b1 - end param + * + * history({end: 1609545600000}) must send end=1609545600000 as a query parameter. + */ + it('RSP4b1 - history() with end param sends end query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ end: 1609545600000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('end')).to.equal('1609545600000'); + }); + + /** + * RSP4b2 - direction forwards + * + * history({direction: 'forwards'}) must send direction=forwards as a query parameter. + */ + it('RSP4b2 - history() with direction forwards sends direction query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ direction: 'forwards' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('direction')).to.equal('forwards'); + }); + + /** + * RSP4b3 - limit param + * + * history({limit: 50}) must send limit=50 as a query parameter. + */ + it('RSP4b3 - history() with limit param sends limit query parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ limit: 50 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('50'); + }); + + // --------------------------------------------------------------------------- + // RSP5 - Decoding + // --------------------------------------------------------------------------- + + /** + * RSP5a - string data + * + * Plain string data must pass through without modification. + */ + it('RSP5a - get() with plain string data passes through', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ action: 1, clientId: 'user-1', data: 'hello world' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(1); + expect(result.items[0].data).to.equal('hello world'); + }); + + /** + * RSP5b - JSON encoded + * + * When encoding is "json", data must be decoded from JSON string to object, + * and the encoding must be consumed (null after decoding). + */ + it('RSP5b - get() with json encoding decodes data to object', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + action: 1, + clientId: 'user-1', + data: '{"status":"online","count":42}', + encoding: 'json', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(1); + expect(result.items[0].data).to.deep.equal({ status: 'online', count: 42 }); + // Encoding must be consumed after decoding + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSP5e - chained encoding + * + * When encoding is "json/base64", data must be decoded from base64 then JSON. + * The encoding must be fully consumed (null after decoding). + */ + it('RSP5e - get() with chained json/base64 encoding decodes correctly', async function () { + // {"key":"value"} base64-encoded + const jsonStr = '{"key":"value"}'; + const base64Data = Buffer.from(jsonStr).toString('base64'); + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + action: 1, + clientId: 'user-1', + data: base64Data, + encoding: 'json/base64', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(1); + expect(result.items[0].data).to.deep.equal({ key: 'value' }); + // All encoding layers must be consumed + expect(result.items[0].encoding).to.be.null; + }); + + // --------------------------------------------------------------------------- + // Pagination + // --------------------------------------------------------------------------- + + /** + * RSP pagination - get with Link header + * + * When the server responds with a Link header containing a "next" relation, + * hasNext() must return true and isLast() must return false. + */ + it('RSP pagination - get() with Link header indicates hasNext', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ action: 1, clientId: 'user-1', data: 'hello' }], { + Link: '<./presence?cursor=abc&limit=1>; rel="next"', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({ limit: 1 }); + + expect(result.items).to.have.length(1); + expect(result.hasNext()).to.be.true; + expect(result.isLast()).to.be.false; + }); + + /** + * RSP pagination - history next page + * + * Navigating pages via next() must fetch the next page from the server. + */ + it('RSP pagination - history() navigates pages via next()', async function () { + let reqCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + reqCount++; + if (reqCount === 1) { + req.respond_with(200, [{ action: 2, clientId: 'alice', timestamp: 1609459200000 }], { + Link: '<./presence?cursor=page2&limit=1>; rel="next"', + }); + } else { + req.respond_with(200, [{ action: 3, clientId: 'bob', timestamp: 1609459100000 }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + // First page + const page1 = await channel.presence.history({ limit: 1 }); + expect(page1.items).to.have.length(1); + expect(page1.items[0].action).to.equal('enter'); + expect(page1.items[0].clientId).to.equal('alice'); + expect(page1.hasNext()).to.be.true; + + // Second page + const page2 = await page1.next(); + expect(page2!.items).to.have.length(1); + expect(page2!.items[0].action).to.equal('leave'); + expect(page2!.items[0].clientId).to.equal('bob'); + expect(page2!.hasNext()).to.be.false; + expect(page2!.isLast()).to.be.true; + }); + + // --------------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------------- + + /** + * RSP error - server error + * + * When the server responds with a 500 error, the operation must throw + * with the appropriate error code. + */ + it('RSP error - server error on get() throws with error code', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + error: { + code: 50000, + statusCode: 500, + message: 'Internal server error', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + try { + await channel.presence.get({}); + expect.fail('Expected get() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + expect(error.code).to.equal(50000); + } + }); + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + /** + * RSP actions - all actions mapped + * + * Wire actions 1-4 must be decoded to present/enter/leave/update strings. + */ + it('RSP actions - wire actions 1-4 decoded to correct strings', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { action: 1, clientId: 'u1' }, + { action: 2, clientId: 'u2' }, + { action: 3, clientId: 'u3' }, + { action: 4, clientId: 'u4' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(4); + + const expected = [ + { wire: 1, str: 'present' }, + { wire: 2, str: 'enter' }, + { wire: 3, str: 'leave' }, + { wire: 4, str: 'update' }, + ]; + + for (let i = 0; i < expected.length; i++) { + expect(result.items[i].action).to.equal( + expected[i].str, + 'wire action ' + expected[i].wire + ' should decode to ' + expected[i].str, + ); + } + }); + + // --------------------------------------------------------------------------- + // RSP3a1b - get() limit defaults to 100 + // --------------------------------------------------------------------------- + + /** + * RSP3a1b - limit defaults to 100 + * + * When get() is called without a limit parameter, the request must either + * omit the limit param (server default) or send limit=100. + */ + it('RSP3a1b - get() limit defaults to 100', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({}); + + expect(captured).to.have.length(1); + const params = captured[0].url.searchParams; + const limit = params.get('limit'); + // limit should either be absent (null) or '100' + expect(limit === null || limit === '100').to.be.true; + }); + + // --------------------------------------------------------------------------- + // RSP3 - get() with combined filters + // --------------------------------------------------------------------------- + + /** + * RSP3 - combined filters + * + * get() with limit, clientId, and connectionId must send all three as + * query parameters. + */ + it('RSP3 - get() with combined filters sends all params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({ limit: 25, clientId: 'user1', connectionId: 'conn1' }); + + expect(captured).to.have.length(1); + const params = captured[0].url.searchParams; + expect(params.get('limit')).to.equal('25'); + expect(params.get('clientId')).to.equal('user1'); + expect(params.get('connectionId')).to.equal('conn1'); + }); + + // --------------------------------------------------------------------------- + // RSP4b1c - history() with start and end combined + // --------------------------------------------------------------------------- + + /** + * RSP4b1c - start and end combined + * + * history() with both start and end must send both as query parameters. + */ + it('RSP4b1c - history() with start and end combined sends both params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ start: 1609459200000, end: 1609545600000 }); + + expect(captured).to.have.length(1); + const params = captured[0].url.searchParams; + expect(params.get('start')).to.equal('1609459200000'); + expect(params.get('end')).to.equal('1609545600000'); + }); + + // --------------------------------------------------------------------------- + // RSP4b3b - history() limit defaults to 100 + // --------------------------------------------------------------------------- + + /** + * RSP4b3b - history limit defaults to 100 + * + * When history() is called without a limit parameter, the request must either + * omit the limit param (server default) or send limit=100. + */ + it('RSP4b3b - history() limit defaults to 100', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({}); + + expect(captured).to.have.length(1); + const params = captured[0].url.searchParams; + const limit = params.get('limit'); + // limit should either be absent (null) or '100' + expect(limit === null || limit === '100').to.be.true; + }); + + // --------------------------------------------------------------------------- + // RSP4 - history() with all parameters + // --------------------------------------------------------------------------- + + /** + * RSP4 - all parameters combined + * + * history() with start, end, direction, and limit must send all four + * as query parameters. + */ + it('RSP4 - history() with all parameters sends all params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ start: 1609459200000, end: 1609545600000, direction: 'forwards', limit: 50 }); + + expect(captured).to.have.length(1); + const params = captured[0].url.searchParams; + expect(params.get('start')).to.equal('1609459200000'); + expect(params.get('end')).to.equal('1609545600000'); + expect(params.get('direction')).to.equal('forwards'); + expect(params.get('limit')).to.equal('50'); + }); + + // --------------------------------------------------------------------------- + // RSP Error 2 - auth error on history() + // --------------------------------------------------------------------------- + + /** + * RSP Error 2 - auth error on history + * + * When the server responds with 401 and error code 40101, the operation + * must throw with the appropriate error code and statusCode. + */ + it('RSP Error 2 - auth error on history() throws with error code', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(401, { + error: { + code: 40101, + statusCode: 401, + message: 'Unauthorized', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + try { + await channel.presence.history({}); + expect.fail('Expected history() to throw'); + } catch (error: any) { + expect(error.code).to.equal(40101); + expect(error.statusCode).to.equal(401); + } + }); + + // --------------------------------------------------------------------------- + // RSP Headers - get() includes standard headers + // --------------------------------------------------------------------------- + + /** + * RSP Headers - standard headers + * + * get() must include authorization, X-Ably-Version, and accept headers + * in the request. + */ + it('RSP Headers - get() includes standard headers', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({}); + + expect(captured).to.have.length(1); + const headers = captured[0].headers; + expect(headers).to.have.property('authorization'); + expect(headers['authorization']).to.not.be.empty; + expect(headers).to.have.property('X-Ably-Version'); + expect(headers['X-Ably-Version']).to.not.be.empty; + expect(headers).to.have.property('accept'); + expect(headers['accept']).to.not.be.empty; + }); +}); diff --git a/test/uts/rest/push/push_admin_publish.test.ts b/test/uts/rest/push/push_admin_publish.test.ts new file mode 100644 index 0000000000..30446a763c --- /dev/null +++ b/test/uts/rest/push/push_admin_publish.test.ts @@ -0,0 +1,223 @@ +/** + * UTS: Push Admin Publish Tests + * + * Spec points: RSH1, RSH1a + * Source: uts/test/rest/unit/push/push_admin_publish.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/push/push_admin_publish', function () { + afterEach(restoreAll); + + /** + * RSH1a - publish sends POST to /push/publish + * + * push.admin.publish() must issue a POST request to /push/publish + * with the recipient and data fields in the body. + */ + it('RSH1a - publish sends POST to /push/publish', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.publish( + { transportType: 'apns', deviceToken: 'foo' }, + { notification: { title: 'Test', body: 'Hello' } }, + ); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('post'); + expect(captured[0].path).to.equal('/push/publish'); + }); + + /** + * RSH1a - body contains recipient and data + * + * The POST body must contain the recipient object and the payload + * fields (notification, data) merged at the top level. + */ + it('RSH1a - body contains recipient and data', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.publish( + { transportType: 'apns', deviceToken: 'foo' }, + { notification: { title: 'Test', body: 'Hello' } }, + ); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.recipient.transportType).to.equal('apns'); + expect(body.recipient.deviceToken).to.equal('foo'); + expect(body.notification.title).to.equal('Test'); + expect(body.notification.body).to.equal('Hello'); + }); + + /** + * RSH1a - recipient as clientId + * + * publish() works with a clientId-based recipient. + */ + it('RSH1a - recipient as clientId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.publish({ clientId: 'user-123' }, { data: { key: 'value' } }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.recipient.clientId).to.equal('user-123'); + expect(body.data.key).to.equal('value'); + }); + + /** + * RSH1a - recipient as deviceId + * + * publish() works with a deviceId-based recipient. + */ + it('RSH1a - recipient as deviceId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.publish({ deviceId: 'device-abc' }, { notification: { title: 'Device Push' } }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.recipient.deviceId).to.equal('device-abc'); + expect(body.notification.title).to.equal('Device Push'); + }); + + /** + * RSH1a - data contains notification fields + * + * The payload notification and data fields are included in the + * request body alongside the recipient. + */ + it('RSH1a - data contains notification fields', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.publish( + { clientId: 'user-1' }, + { + notification: { title: 'Alert', body: 'Something happened' }, + data: { eventType: 'alert', severity: 'high' }, + }, + ); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.notification.title).to.equal('Alert'); + expect(body.notification.body).to.equal('Something happened'); + expect(body.data.eventType).to.equal('alert'); + expect(body.data.severity).to.equal('high'); + }); + + /** + * RSH1a - auth header included + * + * The publish request must include an Authorization header + * for authentication. + */ + it('RSH1a - auth header included', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.publish({ clientId: 'user-1' }, { notification: { title: 'Test' } }); + + expect(captured).to.have.length(1); + expect(captured[0].headers.authorization).to.match(/^Basic /); + }); + + /** + * RSH1 - client.push.admin exposes PushAdmin + * + * The client.push property must exist and expose admin with + * deviceRegistrations and channelSubscriptions sub-objects. + */ + it('RSH1 - client.push.admin exposes PushAdmin', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + expect(client.push).to.exist; + expect(client.push.admin).to.exist; + expect(client.push.admin.deviceRegistrations).to.exist; + expect(client.push.admin.channelSubscriptions).to.exist; + }); + + /** + * RSH1a - publish propagates server error + * + * When the server returns an error response, publish() must + * propagate it as an exception with the correct error code. + */ + it('RSH1a - publish propagates server error', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40000, statusCode: 400, message: 'Invalid request' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.push.admin.publish({ clientId: 'user-1' }, { notification: { title: 'Test' } }); + expect.fail('Expected publish to throw'); + } catch (err: any) { + expect(err.code).to.equal(40000); + } + }); +}); diff --git a/test/uts/rest/push/push_channel_subscriptions.test.ts b/test/uts/rest/push/push_channel_subscriptions.test.ts new file mode 100644 index 0000000000..6bdec6efd4 --- /dev/null +++ b/test/uts/rest/push/push_channel_subscriptions.test.ts @@ -0,0 +1,416 @@ +/** + * UTS: Push Channel Subscriptions Tests + * + * Spec points: RSH1c, RSH1c1 (list), RSH1c2 (listChannels), RSH1c3 (save), RSH1c5 (removeWhere) + * Source: uts/test/rest/unit/push/push_channel_subscriptions.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/push/push_channel_subscriptions', function () { + afterEach(restoreAll); + + /** + * RSH1c3 - save sends POST to /push/channelSubscriptions + * + * save() issues a POST request to the channelSubscriptions endpoint + * with the subscription in the body. + */ + it('RSH1c3 - save sends POST to /push/channelSubscriptions', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channel: 'my-channel', + deviceId: 'device-001', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.save({ + channel: 'my-channel', + deviceId: 'device-001', + }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('post'); + expect(captured[0].path).to.equal('/push/channelSubscriptions'); + }); + + /** + * RSH1c3 - save body contains channel and subscription details + * + * The POST body must contain the channel name and either + * deviceId or clientId. The response is parsed into a + * PushChannelSubscription object. + */ + it('RSH1c3 - save body contains channel and subscription details', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channel: 'my-channel', + deviceId: 'device-001', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.push.admin.channelSubscriptions.save({ + channel: 'my-channel', + deviceId: 'device-001', + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.channel).to.equal('my-channel'); + expect(body.deviceId).to.equal('device-001'); + + // Response is parsed as PushChannelSubscription + expect(result.channel).to.equal('my-channel'); + expect(result.deviceId).to.equal('device-001'); + }); + + /** + * RSH1c1 - list sends GET to /push/channelSubscriptions + * + * list() issues a GET request to the channelSubscriptions endpoint. + */ + it('RSH1c1 - list sends GET to /push/channelSubscriptions', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [{ channel: 'my-channel', deviceId: 'device-001' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.list({}); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/push/channelSubscriptions'); + }); + + /** + * RSH1c1 - list with channel filter + * + * list() forwards the channel parameter as a query parameter + * and returns matching subscriptions. + */ + it('RSH1c1 - list with channel filter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { channel: 'my-channel', deviceId: 'device-001' }, + { channel: 'my-channel', clientId: 'client-abc' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.list({ channel: 'my-channel' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('channel')).to.equal('my-channel'); + }); + + /** + * RSH1c1 - list returns PaginatedResult + * + * list() returns a PaginatedResult containing PushChannelSubscription objects. + */ + it('RSH1c1 - list returns PaginatedResult', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { channel: 'my-channel', deviceId: 'device-001' }, + { channel: 'my-channel', clientId: 'client-abc' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.push.admin.channelSubscriptions.list({ channel: 'my-channel' }); + + expect(result.items).to.have.length(2); + expect((result.items[0] as any).channel).to.equal('my-channel'); + expect((result.items[0] as any).deviceId).to.equal('device-001'); + expect((result.items[1] as any).clientId).to.equal('client-abc'); + }); + + /** + * RSH1c5 - removeWhere sends DELETE to /push/channelSubscriptions + * + * removeWhere() issues a DELETE request to the channelSubscriptions + * endpoint with filter parameters as query params. + */ + it('RSH1c5 - removeWhere sends DELETE to /push/channelSubscriptions', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.removeWhere({ clientId: 'client-abc' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/channelSubscriptions'); + expect(captured[0].url.searchParams.get('clientId')).to.equal('client-abc'); + }); + + /** + * RSH1c5 - removeWhere with channel param + * + * removeWhere() forwards the channel parameter along with other + * filter params to delete matching subscriptions. + */ + it('RSH1c5 - removeWhere with channel param', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.removeWhere({ + channel: 'my-channel', + deviceId: 'device-001', + }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/channelSubscriptions'); + expect(captured[0].url.searchParams.get('channel')).to.equal('my-channel'); + expect(captured[0].url.searchParams.get('deviceId')).to.equal('device-001'); + }); + + /** + * RSH1c2 - listChannels sends GET to /push/channels + * + * listChannels() issues a GET request to the /push/channels endpoint. + */ + it('RSH1c2 - listChannels sends GET to /push/channels', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, ['channel-1', 'channel-2', 'channel-3']); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.listChannels({}); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/push/channels'); + }); + + /** + * RSH1c2 - listChannels returns PaginatedResult + * + * listChannels() returns a PaginatedResult containing channel + * name strings. + */ + it('RSH1c2 - listChannels returns PaginatedResult', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, ['channel-1', 'channel-2', 'channel-3']); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.push.admin.channelSubscriptions.listChannels({}); + + expect(result.items).to.have.length(3); + expect(result.items[0]).to.equal('channel-1'); + expect(result.items[1]).to.equal('channel-2'); + expect(result.items[2]).to.equal('channel-3'); + }); + + /** + * RSH1c2 - listChannels with params + * + * listChannels() forwards the limit parameter as a query parameter. + */ + it('RSH1c2 - listChannels with params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, ['channel-1']); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.push.admin.channelSubscriptions.listChannels({ limit: '1' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('1'); + expect(result.items).to.have.length(1); + }); + + /** + * RSH1c1 - list with deviceId and clientId filters + * + * list() forwards both deviceId and clientId as query parameters + * when both are provided. + */ + it('RSH1c1 - list with deviceId and clientId filters', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [{ channel: 'my-channel', deviceId: 'device-001', clientId: 'client-abc' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.list({ deviceId: 'device-001', clientId: 'client-abc' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('deviceId')).to.equal('device-001'); + expect(captured[0].url.searchParams.get('clientId')).to.equal('client-abc'); + }); + + /** + * RSH1c1 - list supports limit + * + * list() forwards the limit parameter as a query parameter. + */ + it('RSH1c1 - list supports limit', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [{ channel: 'ch1', deviceId: 'device-001' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.list({ limit: '5' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('5'); + }); + + /** + * RSH1c3 - save propagates server error + * + * When the server returns an error response, save() must + * propagate it as an exception with the correct error code. + */ + it('RSH1c3 - save propagates server error', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40000, statusCode: 400, message: 'Invalid request' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.push.admin.channelSubscriptions.save({ + channel: 'my-channel', + deviceId: 'device-001', + }); + expect.fail('Expected save to throw'); + } catch (err: any) { + expect(err.code).to.equal(40000); + } + }); + + /** + * RSH1c4 - remove with deviceId + * + * remove() issues a DELETE request to the channelSubscriptions + * endpoint with channel and deviceId as query parameters. + */ + it('RSH1c4 - remove with deviceId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.remove({ channel: 'ch', deviceId: 'dev-1' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].url.searchParams.get('channel')).to.equal('ch'); + expect(captured[0].url.searchParams.get('deviceId')).to.equal('dev-1'); + }); + + /** + * RSH1c5 - removeWhere with deviceId + * + * removeWhere() issues a DELETE request with deviceId as a + * query parameter. + */ + it('RSH1c5 - removeWhere with deviceId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.channelSubscriptions.removeWhere({ deviceId: 'device-001' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].url.searchParams.get('deviceId')).to.equal('device-001'); + }); +}); diff --git a/test/uts/rest/push/push_device_registrations.test.ts b/test/uts/rest/push/push_device_registrations.test.ts new file mode 100644 index 0000000000..b005310f8e --- /dev/null +++ b/test/uts/rest/push/push_device_registrations.test.ts @@ -0,0 +1,555 @@ +/** + * UTS: Push Device Registrations Tests + * + * Spec points: RSH1b, RSH1b1 (get), RSH1b2 (list), RSH1b3 (save), RSH1b4 (remove), RSH1b5 (removeWhere) + * Source: uts/test/rest/unit/push/push_device_registrations.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/push/push_device_registrations', function () { + afterEach(restoreAll); + + /** + * RSH1b3 - save sends PUT to /push/deviceRegistrations/{id} + * + * save() issues a PUT request to the device-specific endpoint + * with the device details in the body. + */ + it('RSH1b3 - save sends PUT to /push/deviceRegistrations/{id}', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + metadata: {}, + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + state: 'Active', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.save({ + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + }, + }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('put'); + expect(captured[0].path).to.equal('/push/deviceRegistrations/' + encodeURIComponent('device-001')); + }); + + /** + * RSH1b3 - save body contains device details + * + * The PUT body must contain the device's id, clientId, platform, + * formFactor, and push recipient fields. + */ + it('RSH1b3 - save body contains device details', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + state: 'Active', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.push.admin.deviceRegistrations.save({ + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + }, + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.id).to.equal('device-001'); + expect(body.clientId).to.equal('client-abc'); + expect(body.platform).to.equal('ios'); + expect(body.formFactor).to.equal('phone'); + expect(body.push.recipient.transportType).to.equal('apns'); + + // Response is parsed as DeviceDetails + expect(result.id).to.equal('device-001'); + expect(result.push!.state).to.equal('Active'); + }); + + /** + * RSH1b1 - get sends GET to /push/deviceRegistrations/{id} + * + * get() issues a GET request to the device-specific endpoint. + */ + it('RSH1b1 - get sends GET to /push/deviceRegistrations/{id}', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + id: 'device-001', + clientId: 'client-abc', + formFactor: 'phone', + platform: 'ios', + metadata: { model: 'iPhone 14' }, + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + state: 'Active', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.get('device-001'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/push/deviceRegistrations/' + encodeURIComponent('device-001')); + }); + + /** + * RSH1b1 - get returns device object + * + * get() returns a DeviceDetails object with all the fields + * from the server response. + */ + it('RSH1b1 - get returns device object', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + id: 'device-001', + clientId: 'client-abc', + formFactor: 'phone', + platform: 'ios', + metadata: { model: 'iPhone 14' }, + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + state: 'Active', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const device = await client.push.admin.deviceRegistrations.get('device-001'); + + expect(device.id).to.equal('device-001'); + expect(device.clientId).to.equal('client-abc'); + expect(device.formFactor).to.equal('phone'); + expect(device.platform).to.equal('ios'); + expect(device.push!.recipient!.transportType).to.equal('apns'); + expect(device.push!.state).to.equal('Active'); + }); + + /** + * RSH1b2 - list sends GET to /push/deviceRegistrations + * + * list() issues a GET request to the deviceRegistrations collection endpoint. + */ + it('RSH1b2 - list sends GET to /push/deviceRegistrations', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { recipient: {}, state: 'Active' }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.list({}); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/push/deviceRegistrations'); + }); + + /** + * RSH1b2 - list with params (deviceId filter) + * + * list() forwards the deviceId parameter as a query parameter and + * returns only matching results. + */ + it('RSH1b2 - list with params (deviceId filter)', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { recipient: {}, state: 'Active' }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.list({ deviceId: 'device-001' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('deviceId')).to.equal('device-001'); + }); + + /** + * RSH1b2 - list returns PaginatedResult + * + * list() returns a PaginatedResult containing DeviceDetails objects. + */ + it('RSH1b2 - list returns PaginatedResult', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { recipient: {}, state: 'Active' }, + }, + { + id: 'device-002', + clientId: 'client-abc', + platform: 'android', + formFactor: 'tablet', + push: { recipient: {}, state: 'Active' }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.push.admin.deviceRegistrations.list({ clientId: 'client-abc' }); + + expect(result.items).to.have.length(2); + expect((result.items[0] as any).id).to.equal('device-001'); + expect((result.items[1] as any).id).to.equal('device-002'); + }); + + /** + * RSH1b4 - remove sends DELETE to /push/deviceRegistrations/{id} + * + * remove() issues a DELETE request to the device-specific endpoint. + */ + it('RSH1b4 - remove sends DELETE to /push/deviceRegistrations/{id}', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.remove('device-001'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/deviceRegistrations/' + encodeURIComponent('device-001')); + }); + + /** + * RSH1b4 - remove accepts string deviceId + * + * remove() accepts a plain string deviceId (not just a DeviceDetails object). + */ + it('RSH1b4 - remove accepts string deviceId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + // Pass a plain string, not a DeviceDetails object + await client.push.admin.deviceRegistrations.remove('my-device-id'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/deviceRegistrations/' + encodeURIComponent('my-device-id')); + }); + + /** + * RSH1b5 - removeWhere sends DELETE to /push/deviceRegistrations with params + * + * removeWhere() issues a DELETE request to the collection endpoint + * with filter parameters as query params. + */ + it('RSH1b5 - removeWhere sends DELETE to /push/deviceRegistrations with params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.removeWhere({ clientId: 'client-abc' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/deviceRegistrations'); + expect(captured[0].url.searchParams.get('clientId')).to.equal('client-abc'); + }); + + /** + * RSH1b1 - get returns 404 for unknown device + * + * When the server returns a 404 for an unknown deviceId, get() + * must propagate it as an exception with error code 40400. + */ + it('RSH1b1 - get returns 404 for unknown device', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(404, { + error: { code: 40400, statusCode: 404, message: 'Not found' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.push.admin.deviceRegistrations.get('unknown-device'); + expect.fail('Expected get to throw'); + } catch (err: any) { + expect(err.code).to.equal(40400); + } + }); + + /** + * RSH1b1 - get URL-encodes deviceId + * + * get() must URL-encode the deviceId in the request path so that + * special characters are handled correctly. + */ + it('RSH1b1 - get URL-encodes deviceId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + id: 'device/special:id', + platform: 'ios', + formFactor: 'phone', + push: { recipient: {}, state: 'Active' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.get('device/special:id'); + + expect(captured).to.have.length(1); + expect(captured[0].path).to.equal('/push/deviceRegistrations/' + encodeURIComponent('device/special:id')); + }); + + /** + * RSH1b2 - list with clientId filter + * + * list() forwards the clientId parameter as a query parameter. + */ + it('RSH1b2 - list with clientId filter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + id: 'device-001', + clientId: 'client-abc', + platform: 'ios', + formFactor: 'phone', + push: { recipient: {}, state: 'Active' }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.list({ clientId: 'client-abc' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('clientId')).to.equal('client-abc'); + }); + + /** + * RSH1b2 - list supports limit + * + * list() forwards the limit parameter as a query parameter. + */ + it('RSH1b2 - list supports limit', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + id: 'device-001', + platform: 'ios', + formFactor: 'phone', + push: { recipient: {}, state: 'Active' }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.list({ limit: '2' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('2'); + }); + + /** + * RSH1b3 - save propagates server error + * + * When the server returns an error response, save() must + * propagate it as an exception with the correct error code. + */ + it('RSH1b3 - save propagates server error', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + error: { code: 40000, statusCode: 400, message: 'Invalid request' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.push.admin.deviceRegistrations.save({ + id: 'device-001', + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-123' }, + }, + }); + expect.fail('Expected save to throw'); + } catch (err: any) { + expect(err.code).to.equal(40000); + } + }); + + /** + * RSH1b4 - remove nonexistent succeeds + * + * remove() for a nonexistent device should not throw when the + * server returns a successful response. + */ + it('RSH1b4 - remove nonexistent succeeds', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.remove('nonexistent'); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/deviceRegistrations/' + encodeURIComponent('nonexistent')); + }); + + /** + * RSH1b5 - removeWhere with deviceId + * + * removeWhere() forwards the deviceId parameter as a query + * parameter in the DELETE request. + */ + it('RSH1b5 - removeWhere with deviceId', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.push.admin.deviceRegistrations.removeWhere({ deviceId: 'device-001' }); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('delete'); + expect(captured[0].path).to.equal('/push/deviceRegistrations'); + expect(captured[0].url.searchParams.get('deviceId')).to.equal('device-001'); + }); +}); diff --git a/test/uts/rest/request.test.ts b/test/uts/rest/request.test.ts new file mode 100644 index 0000000000..8652a88d1e --- /dev/null +++ b/test/uts/rest/request.test.ts @@ -0,0 +1,469 @@ +/** + * UTS: REST client.request() and HttpPaginatedResponse Tests + * + * Spec points: RSC19, RSC19b, RSC19c, RSC19d, RSC19f, RSC19f1, HP1, HP3, HP4, HP5, HP6, HP7, HP8 + * Source: uts/test/rest/unit/request.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/request', function () { + afterEach(function () { + restoreAll(); + }); + + // --------------------------------------------------------------------------- + // RSC19f — HTTP methods + // --------------------------------------------------------------------------- + + describe('RSC19f - HTTP method support', function () { + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + + methods.forEach(function (method) { + it(`${method} request to /test`, async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request(method, '/test', 3, null as any, null as any, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal(method.toLowerCase()); + expect(captured[0].path).to.equal('/test'); + }); + }); + }); + + // --------------------------------------------------------------------------- + // RSC19f — Request details + // --------------------------------------------------------------------------- + + describe('RSC19f - Request details', function () { + it('query params sent correctly', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request( + 'GET', + '/channels/test/messages', + 3, + { limit: '10', direction: 'backwards' }, + null as any, + null as any, + ); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('10'); + expect(captured[0].url.searchParams.get('direction')).to.equal('backwards'); + }); + + it('custom headers included', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request('GET', '/test', 3, null as any, null as any, { + 'X-Custom-Header': 'custom-value', + 'X-Another': 'another-value', + }); + + expect(captured).to.have.length(1); + expect(captured[0].headers['X-Custom-Header']).to.equal('custom-value'); + expect(captured[0].headers['X-Another']).to.equal('another-value'); + }); + + it('Basic auth header included automatically', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('authorization'); + expect(captured[0].headers['authorization']).to.match(/^Basic /); + + // Verify the base64 encoded credentials + const b64 = captured[0].headers['authorization'].substring(6); + const decoded = Buffer.from(b64, 'base64').toString(); + expect(decoded).to.equal('appId.keyId:keySecret'); + }); + + it('body encoding (JSON)', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request( + 'POST', + '/channels/test/messages', + 3, + null as any, + { name: 'event', data: 'payload' }, + null as any, + ); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body.name).to.equal('event'); + expect(body.data).to.equal('payload'); + }); + }); + + // --------------------------------------------------------------------------- + // HP — HttpPaginatedResponse properties + // --------------------------------------------------------------------------- + + describe('HP - HttpPaginatedResponse', function () { + it('HP4 - statusCode from response', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('POST', '/test', 3, null as any, { data: 'test' }, null as any); + + expect(response.statusCode).to.equal(201); + }); + + it('HP5 - success=true for 2xx', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(response.success).to.be.true; + }); + + it('HP5 - success=false for 4xx', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { error: { code: 40000, message: 'Bad request' } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(response.statusCode).to.equal(400); + expect(response.success).to.be.false; + }); + + it('HP6 - errorCode from error response', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with( + 401, + { error: { code: 40101, message: 'Unauthorized' } }, + { + 'X-Ably-Errorcode': '40101', + }, + ); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(response.errorCode).to.equal(40101); + }); + + it('HP7 - errorMessage from error response', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with( + 401, + { error: { code: 40101, message: 'Unauthorized' } }, + { + 'X-Ably-Errorcode': '40101', + 'X-Ably-Errormessage': 'Token expired', + }, + ); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + // errorMessage comes from the error body, not the header + expect(response.errorMessage).to.be.a('string'); + expect(response.errorMessage).to.equal('Unauthorized'); + }); + + it('HP3 - items array from response body', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { id: 'msg1', name: 'event1', data: 'data1' }, + { id: 'msg2', name: 'event2', data: 'data2' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/channels/test/messages', 3, null as any, null as any, null as any); + + expect(response.items).to.have.length(2); + expect((response.items[0] as any).id).to.equal('msg1'); + expect((response.items[1] as any).id).to.equal('msg2'); + }); + + it('HP8 - response headers accessible', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [], { + 'X-Request-Id': 'req-123', + 'X-Custom-Header': 'custom-value', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(response.headers['X-Request-Id']).to.equal('req-123'); + expect(response.headers['X-Custom-Header']).to.equal('custom-value'); + }); + + it('HP1 - pagination: hasNext/isLast with Link header', async function () { + let reqCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + reqCount++; + if (reqCount === 1) { + req.respond_with(200, [{ id: '1' }, { id: '2' }], { + Link: '<./messages?cursor=abc>; rel="next"', + }); + } else { + req.respond_with(200, [{ id: '3' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/channels/test/messages', 3, null as any, null as any, null as any); + + expect(response.items).to.have.length(2); + expect(response.hasNext()).to.be.true; + expect(response.isLast()).to.be.false; + }); + + it('HP1 - pagination: next() fetches next page', async function () { + let reqCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + reqCount++; + if (reqCount === 1) { + req.respond_with(200, [{ id: '1' }, { id: '2' }], { + Link: '<./messages?cursor=abc>; rel="next"', + }); + } else { + req.respond_with(200, [{ id: '3' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const page1 = await client.request('GET', '/channels/test/messages', 3, null as any, null as any, null as any); + + expect(page1.items).to.have.length(2); + expect(page1.hasNext()).to.be.true; + + const page2 = await page1.next(); + expect(page2!.items).to.have.length(1); + expect((page2!.items[0] as any).id).to.equal('3'); + expect(page2!.hasNext()).to.be.false; + expect(page2!.isLast()).to.be.true; + }); + }); + + // --------------------------------------------------------------------------- + // RSC19 — Error handling + // --------------------------------------------------------------------------- + + describe('RSC19 - Error handling', function () { + it('404 returns HPR with statusCode=404, success=false', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(404, { error: { code: 40400, message: 'Not found' } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/nonexistent', 3, null as any, null as any, null as any); + + expect(response.statusCode).to.equal(404); + expect(response.success).to.be.false; + expect(response.errorCode).to.equal(40400); + }); + + it('500 returns HPR with statusCode=500, success=false', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { error: { code: 50000, message: 'Internal error' } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(response.statusCode).to.equal(500); + expect(response.success).to.be.false; + expect(response.errorCode).to.equal(50000); + }); + + it('Token auth request uses Bearer authorization', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + useBinaryProtocol: false, + authCallback: (params: any, callback: any) => { + callback(null, 'my-token'); + }, + }); + await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('authorization'); + expect(captured[0].headers['authorization']).to.match(/^Bearer /); + }); + + /** + * Path normalization - ably-js does not normalize paths without leading slash. + * The path is appended directly to the base URI, so 'test' without '/' may + * cause a malformed URL or unexpected path. This test verifies ably-js + * behavior: path is used as-is and the leading slash comes from the base URI. + */ + it('Path normalization - path with leading slash', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].path).to.equal('/test'); + }); + + /** + * Network error handling - connection refused propagates as error. + * When the mock refuses the connection, client.request() throws + * rather than returning a response object. + */ + it('Network error handling - connection refused', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_refused(), + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.request('GET', '/test', 3, null as any, null as any, null as any); + expect.fail('Expected request to throw on connection refused'); + } catch (error: any) { + expect(error).to.exist; + } + }); + }); + + // --------------------------------------------------------------------------- + // MsgPack tests — PENDING (mock HTTP does not support msgpack encoding) + // --------------------------------------------------------------------------- + + it('RSC19c - msgpack request headers', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSC19c - msgpack request body encoding', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSC19c - msgpack response decoding', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); +}); diff --git a/test/uts/rest/request_endpoint.test.ts b/test/uts/rest/request_endpoint.test.ts new file mode 100644 index 0000000000..e831b41b2b --- /dev/null +++ b/test/uts/rest/request_endpoint.test.ts @@ -0,0 +1,158 @@ +/** + * UTS: Request Endpoint Configuration Tests + * + * Spec points: RSC25 + * Source: specification/uts/rest/unit/request_endpoint.md + * + * Tests that REST requests are sent to the correct host based on + * endpoint configuration, and that fallback behavior works correctly. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/request_endpoint', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSC25 - Default primary domain used for requests + * + * When no endpoint configuration is provided, REST requests must be + * sent to the default primary domain (main.realtime.ably.net). + */ + it('RSC25 - default primary domain', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('main.realtime.ably.net'); + }); + + /** + * RSC25 - Custom endpoint used for requests + * + * When a custom endpoint (e.g. 'sandbox') is configured, REST requests + * must be sent to the corresponding domain. + */ + it('RSC25 - custom endpoint', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + endpoint: 'sandbox', + }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('sandbox.realtime.ably.net'); + }); + + /** + * RSC25 - Multiple requests all go to primary domain + * + * Successive requests should continue using the primary domain + * without host switching (absent any fallback triggering errors). + */ + it('RSC25 - multiple requests use primary domain', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + await client.time(); + await client.time(); + await client.time(); + + expect(captured).to.have.length(3); + expect(captured[0].url.hostname).to.equal('main.realtime.ably.net'); + expect(captured[1].url.hostname).to.equal('main.realtime.ably.net'); + expect(captured[2].url.hostname).to.equal('main.realtime.ably.net'); + }); + + /** + * RSC25 - Primary domain tried first before fallback + * + * When the primary host fails with a 500 error, the client should + * try the primary first, then fall back to a different host. + */ + it('RSC25 - primary tried before fallback', async function () { + let requestCount = 0; + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + captured.push(req); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + await client.time(); + + expect(captured).to.have.length(2); + // First request goes to primary + expect(captured[0].url.hostname).to.equal('main.realtime.ably.net'); + // Second request goes to a fallback (not primary) + expect(captured[1].url.hostname).to.not.equal('main.realtime.ably.net'); + }); + + /** + * RSC25 - Request path preserved + * + * The request path and method must be correctly constructed + * regardless of endpoint configuration. + */ + it('RSC25 - request path preserved', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + await client.channels.get('test-channel').history(null); + + expect(captured).to.have.length(1); + expect(captured[0].url.hostname).to.equal('main.realtime.ably.net'); + expect(captured[0].path).to.equal('/channels/test-channel/messages'); + expect(captured[0].method).to.equal('get'); + }); +}); diff --git a/test/uts/rest/rest_client.test.ts b/test/uts/rest/rest_client.test.ts new file mode 100644 index 0000000000..9ff3687aef --- /dev/null +++ b/test/uts/rest/rest_client.test.ts @@ -0,0 +1,259 @@ +/** + * UTS: REST Client Tests + * + * Spec points: RSC5, RSC7, RSC7c, RSC7d, RSC7e, RSC8a-c, RSC17, RSC18 + * Source: uts/test/rest/unit/rest_client.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/rest_client', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSC5 - Auth attribute accessible + */ + it('RSC5 - client.auth is accessible', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + expect(client.auth).to.not.be.null; + expect(client.auth).to.not.be.undefined; + }); + + /** + * RSC7e - X-Ably-Version header + * + * All REST requests must include the X-Ably-Version header with a version string. + */ + it('RSC7e - X-Ably-Version header is sent', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + await client.time(); + + expect(captured).to.have.length(1); + // ably-js sends headers with their original casing + expect(captured[0].headers).to.have.property('X-Ably-Version'); + expect(captured[0].headers['X-Ably-Version']).to.match(/[0-9.]+/); + }); + + /** + * RSC7d - Ably-Agent header + * + * All REST requests must include the Ably-Agent header identifying the library. + */ + it('RSC7d - Ably-Agent header is sent', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('Ably-Agent'); + expect(captured[0].headers['Ably-Agent']).to.match(/ably-js\/[0-9]+\.[0-9]+/); + }); + + /** + * RSC7c - Request ID when addRequestIds enabled + * + * When addRequestIds is true, all requests must include a request_id query parameter. + */ + /** + * NOTE: ably-js accepts addRequestIds option but does not implement it. + * The option is stored but no request_id parameter is added to requests. + * See deviations.md. + */ + it('RSC7c - request_id query param when addRequestIds is true', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', addRequestIds: true } as any); + await client.time(); + + expect(captured).to.have.length(1); + const requestId = captured[0].url.searchParams.get('request_id'); + expect(requestId).to.be.a('string'); + expect(requestId.length).to.be.at.least(12); + }); + + /** + * RSC8a/RSC8b - Protocol content type + * + * With useBinaryProtocol: false, Content-Type should be application/json. + */ + it('RSC8a/RSC8b - JSON content type when useBinaryProtocol is false', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('e', 'd'); + + expect(captured).to.have.length(1); + expect(captured[0].headers['content-type']).to.include('application/json'); + }); + + /** + * RSC8c - Accept header + * + * Accept header must match the configured protocol. + */ + it('RSC8c - Accept header is application/json', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { serials: ['s1'] }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.channels.get('test').publish('e', 'd'); + + expect(captured).to.have.length(1); + expect(captured[0].headers['accept']).to.include('application/json'); + }); + + /** + * RSC17 - clientId attribute + * + * When clientId is set in ClientOptions, Auth#clientId reflects it. + */ + it('RSC17 - clientId from options is accessible via auth.clientId', function () { + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + clientId: 'explicit-client', + }); + expect(client.auth.clientId).to.equal('explicit-client'); + }); + + /** + * RSC18 - TLS: true uses HTTPS (default) + */ + it('RSC18 - default TLS uses HTTPS', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.protocol).to.equal('https:'); + }); + + /** + * RSC18 - TLS: false uses HTTP + */ + it('RSC18 - tls:false uses HTTP', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1234567890000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ token: 'tok', tls: false }); + await client.time(); + + expect(captured).to.have.length(1); + expect(captured[0].url.protocol).to.equal('http:'); + }); + + /** + * RSC6 - stats() basic request + * + * Verify that stats() sends a GET request to /stats. + */ + it('RSC6 - stats() sends GET /stats', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + try { + await client.stats({} as any); + } catch (e) { + // Response parsing may fail — we only care about the request + } + + expect(captured).to.have.length.at.least(1); + expect(captured[0].method.toUpperCase()).to.equal('GET'); + expect(captured[0].path).to.equal('/stats'); + }); + + // --------------------------------------------------------------------------- + // MsgPack tests — PENDING (mock HTTP does not support msgpack encoding) + // --------------------------------------------------------------------------- + + it('RSC8a - default msgpack protocol Content-Type', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSC8d - mismatched Content-Type response decoded', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSC8e - unsupported Content-Type response error', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); + + it('RSC8 - msgpack error response decoded', function () { + // PENDING: Requires mock msgpack encoding support. See deviations.md. + this.skip(); + }); +}); diff --git a/test/uts/rest/stats.test.ts b/test/uts/rest/stats.test.ts new file mode 100644 index 0000000000..27d617106e --- /dev/null +++ b/test/uts/rest/stats.test.ts @@ -0,0 +1,548 @@ +/** + * UTS: REST Stats API Tests + * + * Spec points: RSC6, RSC6a, RSC6b1, RSC6b2, RSC6b3, RSC6b4 + * Source: uts/test/rest/unit/stats.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/stats', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSC6a - stats() returns PaginatedResult with Stats objects + * + * The stats() method makes a GET request to /stats and returns a + * PaginatedResult containing Stats objects. + */ + it('RSC6a - stats() returns PaginatedResult with Stats objects', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + intervalId: '2024-01-01:00:00', + unit: 'hour', + all: { messages: { count: 100, data: 5000 }, all: { count: 100, data: 5000 } }, + }, + { + intervalId: '2024-01-01:01:00', + unit: 'hour', + all: { messages: { count: 150, data: 7500 }, all: { count: 150, data: 7500 } }, + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.stats({} as any); + + // Result should be a PaginatedResult with 2 items + expect(result.items).to.have.length(2); + expect(result.items[0].intervalId).to.equal('2024-01-01:00:00'); + expect(result.items[1].intervalId).to.equal('2024-01-01:01:00'); + }); + + /** + * RSC6a - stats() sends GET /stats + * + * The stats endpoint must be accessed via GET /stats. + */ + it('RSC6a - stats() sends GET /stats', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({} as any); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/stats'); + }); + + /** + * RSC6a - stats() sends authenticated request with standard headers + * + * The /stats endpoint requires authentication. Requests must include + * valid credentials and standard Ably headers. + */ + it('RSC6a - stats() sends authenticated request with standard headers', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({} as any); + + expect(captured).to.have.length(1); + const request = captured[0]; + + // Request must be authenticated + expect(request.headers.authorization).to.match(/^Basic /); + + // Standard Ably headers must be present + expect(request.headers).to.have.property('X-Ably-Version'); + expect(request.headers).to.have.property('Ably-Agent'); + }); + + /** + * RSC6a - stats() with no parameters sends no query params + * + * When called without parameters, no query parameters should be sent + * (the server applies its own defaults). + */ + it('RSC6a - stats() with no params sends no query params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({} as any); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/stats'); + + // No user-specified query params (format may be sent by SDK) + const params = captured[0].url.searchParams; + expect(params.get('start')).to.be.null; + expect(params.get('end')).to.be.null; + expect(params.get('direction')).to.be.null; + expect(params.get('limit')).to.be.null; + expect(params.get('unit')).to.be.null; + }); + + /** + * RSC6b1 - stats() with start parameter + * + * start is an optional timestamp field represented as milliseconds + * since epoch. + */ + it('RSC6b1 - stats() with start parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ start: 1704067200000 } as any); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1704067200000'); + }); + + /** + * RSC6b1 - stats() with end parameter + * + * end is an optional timestamp field represented as milliseconds + * since epoch. + */ + it('RSC6b1 - stats() with end parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ end: 1706745599000 } as any); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('end')).to.equal('1706745599000'); + }); + + /** + * RSC6b1 - stats() with start and end parameters + * + * Both start and end can be provided together. start must be <= end. + */ + it('RSC6b1 - stats() with start and end parameters', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ start: 1704067200000, end: 1706745599000 } as any); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1704067200000'); + expect(captured[0].url.searchParams.get('end')).to.equal('1706745599000'); + }); + + /** + * RSC6b2 - stats() with direction parameter + * + * direction backwards or forwards; if omitted the direction defaults + * to the REST API default (backwards). + */ + it('RSC6b2 - stats() with direction parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ direction: 'forwards' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('direction')).to.equal('forwards'); + }); + + /** + * RSC6b2 - stats() direction defaults to backwards + * + * When direction is not specified, it is either omitted from the query + * (letting the server apply the default) or sent as "backwards". + */ + it('RSC6b2 - stats() direction defaults to backwards', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({} as any); + + expect(captured).to.have.length(1); + const direction = captured[0].url.searchParams.get('direction'); + expect(direction === null || direction === 'backwards').to.be.true; + }); + + /** + * RSC6b3 - stats() with limit parameter + * + * limit supports up to 1,000 items; if omitted the limit defaults + * to the REST API default (100). + */ + it('RSC6b3 - stats() with limit parameter', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ limit: 10 } as any); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('10'); + }); + + /** + * RSC6b3 - stats() limit defaults to 100 + * + * When limit is not specified, it is either omitted (server default) + * or sent as "100". + */ + it('RSC6b3 - stats() limit defaults to 100', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({} as any); + + expect(captured).to.have.length(1); + const limit = captured[0].url.searchParams.get('limit'); + expect(limit === null || limit === '100').to.be.true; + }); + + /** + * RSC6b4 - stats() with unit parameter (minute) + */ + it('RSC6b4 - stats() with unit=minute', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ unit: 'minute' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('unit')).to.equal('minute'); + }); + + /** + * RSC6b4 - stats() with unit parameter (hour) + */ + it('RSC6b4 - stats() with unit=hour', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ unit: 'hour' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('unit')).to.equal('hour'); + }); + + /** + * RSC6b4 - stats() with unit parameter (day) + */ + it('RSC6b4 - stats() with unit=day', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ unit: 'day' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('unit')).to.equal('day'); + }); + + /** + * RSC6b4 - stats() with unit parameter (month) + */ + it('RSC6b4 - stats() with unit=month', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ unit: 'month' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('unit')).to.equal('month'); + }); + + /** + * RSC6b4 - stats() unit defaults to minute + * + * When unit is not specified, it is either omitted (server default) + * or sent as "minute". + */ + it('RSC6b4 - stats() unit defaults to minute', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({} as any); + + expect(captured).to.have.length(1); + const unit = captured[0].url.searchParams.get('unit'); + expect(unit === null || unit === 'minute').to.be.true; + }); + + /** + * RSC6b - stats() with all parameters combined + * + * All query parameters can be used together in a single request. + */ + it('RSC6b - stats() with all parameters combined', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.stats({ + start: 1704067200000, + end: 1706745599000, + direction: 'forwards', + limit: 50, + unit: 'hour', + } as any); + + expect(captured).to.have.length(1); + const params = captured[0].url.searchParams; + expect(params.get('start')).to.equal('1704067200000'); + expect(params.get('end')).to.equal('1706745599000'); + expect(params.get('direction')).to.equal('forwards'); + expect(params.get('limit')).to.equal('50'); + expect(params.get('unit')).to.equal('hour'); + }); + + /** + * RSC6a - stats() empty results + * + * Must handle empty result sets correctly. + */ + it('RSC6a - stats() empty results', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.stats({} as any); + + expect(result.items).to.have.length(0); + expect(result.hasNext()).to.be.false; + expect(result.isLast()).to.be.true; + }); + + /** + * RSC6a - stats() error handling + * + * Errors from the stats endpoint must be properly propagated to the caller. + */ + it('RSC6a - stats() error handling', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + error: { + message: 'Unauthorized', + code: 40100, + statusCode: 401, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.stats({} as any); + expect.fail('Expected stats() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + expect(error.code).to.equal(40100); + } + }); + + /** + * RSC6a - stats() pagination with Link headers + * + * PaginatedResult supports navigation via Link headers (TG4, TG6). + */ + it('RSC6a - stats() pagination with Link headers', async function () { + const captured: any[] = []; + let reqCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + reqCount++; + if (reqCount === 1) { + req.respond_with(200, [{ intervalId: '2024-01-01:01:00', unit: 'hour' }], { + Link: '<./stats?start=1704070800000&limit=1>; rel="next"', + }); + } else { + req.respond_with(200, [{ intervalId: '2024-01-01:00:00', unit: 'hour' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + // First page + const page1 = await client.stats({ limit: 1 } as any); + expect(page1.items).to.have.length(1); + expect(page1.items[0].intervalId).to.equal('2024-01-01:01:00'); + expect(page1.hasNext()).to.be.true; + expect(page1.isLast()).to.be.false; + + // Second page + const page2 = await page1.next(); + expect(page2!.items).to.have.length(1); + expect(page2!.items[0].intervalId).to.equal('2024-01-01:00:00'); + expect(page2!.hasNext()).to.be.false; + expect(page2!.isLast()).to.be.true; + }); +}); diff --git a/test/uts/rest/time.test.ts b/test/uts/rest/time.test.ts new file mode 100644 index 0000000000..7783c715dd --- /dev/null +++ b/test/uts/rest/time.test.ts @@ -0,0 +1,189 @@ +/** + * UTS: REST Time API Tests + * + * Spec points: RSC16 + * Source: specification/uts/rest/unit/time.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../helpers'; + +describe('uts/rest/time', function () { + let mock; + + afterEach(function () { + restoreAll(); + }); + + /** + * RSC16 - time() returns server time + * + * The time() method retrieves the server time from the /time endpoint + * and returns it as a timestamp. + */ + it('RSC16 - time() returns server time', async function () { + const captured: any[] = []; + const serverTimeMs = 1704067200000; // 2024-01-01 00:00:00 UTC + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [serverTimeMs]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret' }); + const result = await client.time(); + + // Result should match the server timestamp + expect(result).to.be.a('number'); + expect(result).to.equal(serverTimeMs); + + // Verify correct endpoint was called + expect(captured).to.have.length(1); + expect(captured[0].method.toUpperCase()).to.equal('GET'); + expect(captured[0].path).to.equal('/time'); + }); + + /** + * RSC16 - time() request format + * + * The time request must be a GET request to /time with standard Ably headers. + */ + it('RSC16 - time() request format', async function () { + const captured: any[] = []; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret' }); + await client.time(); + + expect(captured).to.have.length(1); + const request = captured[0]; + + // Should be GET request to /time + expect(request.method.toUpperCase()).to.equal('GET'); + expect(request.path).to.equal('/time'); + + // Should have standard Ably headers + expect(request.headers).to.have.property('X-Ably-Version'); + expect(request.headers).to.have.property('Ably-Agent'); + + // Version header should be a version string + expect(request.headers['X-Ably-Version']).to.match(/[0-9.]+/); + + // Agent header should include library name/version + expect(request.headers['Ably-Agent']).to.match(/ably-js\/[0-9]+\.[0-9]+\.[0-9]+/); + }); + + /** + * RSC16 - time() does not require authentication + * + * The /time endpoint does not require authentication and should not send + * an Authorization header, even when credentials are available. + */ + it('RSC16 - time() does not require authentication', async function () { + const captured: any[] = []; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + + // Client has credentials, but time() should not use them + const client = new Ably.Rest({ key: 'app.key:secret' }); + const result = await client.time(); + + // Should succeed + expect(result).to.be.a('number'); + + // Request should not have Authorization header + expect(captured).to.have.length(1); + expect(captured[0].headers).to.not.have.property('Authorization'); + expect(captured[0].headers).to.not.have.property('authorization'); + }); + + /** + * RSC16 - time() works without TLS + * + * The /time endpoint does not require authentication, so it should be + * callable over HTTP (non-TLS). The RSC18 restriction (no basic auth + * over non-TLS) does not apply because time() doesn't send authentication. + */ + it('RSC16 - time() works without TLS', async function () { + const captured: any[] = []; + + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [1704067200000]); + }, + }); + installMockHttp(mock); + + // Client with API key but using token auth to avoid RSC18 restriction + const client = new Ably.Rest({ + key: 'app.key:secret', + tls: false, + useTokenAuth: true, + }); + const result = await client.time(); + + // Should succeed + expect(result).to.be.a('number'); + + // Request should use HTTP (not HTTPS) + expect(captured).to.have.length(1); + expect(captured[0].url.protocol).to.equal('http:'); + + // Request should not have Authorization header + expect(captured[0].headers).to.not.have.property('Authorization'); + expect(captured[0].headers).to.not.have.property('authorization'); + }); + + /** + * RSC16 - time() error handling + * + * Errors from the /time endpoint should be properly propagated to the caller. + */ + it('RSC16 - time() error handling', async function () { + mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + error: { + message: 'Internal server error', + code: 50000, + statusCode: 500, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret' }); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + expect(error.code).to.equal(50000); + } + }); +}); diff --git a/test/uts/rest/types/error_types.test.ts b/test/uts/rest/types/error_types.test.ts new file mode 100644 index 0000000000..5d2494936d --- /dev/null +++ b/test/uts/rest/types/error_types.test.ts @@ -0,0 +1,160 @@ +/** + * UTS: ErrorInfo Type Tests + * + * Spec points: TI1, TI2, TI3, TI4, TI5 + * Source: uts/test/rest/unit/types/error_types.md + */ + +import { expect } from 'chai'; +import { Ably } from '../../helpers'; + +describe('uts/rest/types/error_types', function () { + /** + * TI1 - code attribute + */ + it('TI1 - code attribute', function () { + const error = new Ably.ErrorInfo('Bad request', 40000, 400); + expect(error.code).to.equal(40000); + }); + + /** + * TI2 - statusCode attribute + */ + it('TI2 - statusCode attribute', function () { + const error = new Ably.ErrorInfo('Unauthorized', 40100, 401); + expect(error.statusCode).to.equal(401); + }); + + /** + * TI3 - message attribute + */ + it('TI3 - message attribute', function () { + const error = new Ably.ErrorInfo('Bad request: invalid parameter', 40000, 400); + expect(error.message).to.equal('Bad request: invalid parameter'); + }); + + /** + * TI4 - href attribute (auto-generated from code) + */ + it('TI4 - href attribute', function () { + const error = Ably.ErrorInfo.fromValues({ + code: 40000, + statusCode: 400, + message: 'Bad request', + }); + expect(error.href).to.equal('https://help.ably.io/error/40000'); + }); + + /** + * TI5 - cause attribute + */ + it('TI5 - cause attribute', function () { + const cause = new Error('Network failure'); + const error = Ably.ErrorInfo.fromValues({ + code: 50003, + statusCode: 500, + message: 'Timeout', + cause: cause, + } as any); + expect(error.cause).to.equal(cause); + }); + + /** + * TI - ErrorInfo is an Error instance + */ + it('TI - ErrorInfo is an Error instance', function () { + const error = new Ably.ErrorInfo('test', 40000, 400); + expect(error).to.be.an.instanceOf(Error); + }); + + /** + * TI - ErrorInfo from JSON-like object + */ + it('TI - ErrorInfo from object', function () { + const error = Ably.ErrorInfo.fromValues({ + code: 40100, + statusCode: 401, + message: 'Token expired', + }); + + expect(error.code).to.equal(40100); + expect(error.statusCode).to.equal(401); + expect(error.message).to.equal('Token expired'); + expect(error.href).to.equal('https://help.ably.io/error/40100'); + }); + + /** + * TI - Common error codes + */ + it('TI - common error codes', function () { + const cases = [ + { code: 40000, status: 400, meaning: 'Bad request' }, + { code: 40100, status: 401, meaning: 'Unauthorized' }, + { code: 40101, status: 401, meaning: 'Invalid credentials' }, + { code: 40140, status: 401, meaning: 'Token error' }, + { code: 40142, status: 401, meaning: 'Token expired' }, + { code: 40160, status: 401, meaning: 'Invalid capability' }, + { code: 40300, status: 403, meaning: 'Forbidden' }, + { code: 40400, status: 404, meaning: 'Not found' }, + { code: 50000, status: 500, meaning: 'Internal server error' }, + { code: 50003, status: 500, meaning: 'Timeout' }, + ]; + + for (const tc of cases) { + const error = new Ably.ErrorInfo(tc.meaning, tc.code, tc.status); + expect(error.code).to.equal(tc.code); + expect(error.statusCode).to.equal(tc.status); + } + }); + + /** + * TI - Error string representation + */ + it('TI - string representation', function () { + const error = new Ably.ErrorInfo('Unauthorized: token expired', 40100, 401); + const str = error.toString(); + expect(str).to.include('40100'); + expect(str).to.include('401'); + }); + + /** + * TI5 - nested error cause + * + * When an ErrorInfo is created with a cause that is itself an ErrorInfo, + * the cause's attributes should be accessible. + */ + it('TI5 - nested error cause', function () { + const inner = new Ably.ErrorInfo('inner', 40100, 401); + const outer = Ably.ErrorInfo.fromValues({ + code: 50000, + statusCode: 500, + message: 'Outer error', + cause: inner, + } as any); + + expect(outer.cause).to.equal(inner); + expect(outer.cause!.code).to.equal(40100); + expect(outer.cause!.statusCode).to.equal(401); + expect(outer.cause!.message).to.equal('inner'); + }); + + /** + * TI - ErrorInfo with all attributes + * + * Verify that an ErrorInfo constructed with code, statusCode, message, + * and href exposes all properties correctly. + */ + it('TI - ErrorInfo with all attributes', function () { + const error = Ably.ErrorInfo.fromValues({ + code: 40300, + statusCode: 403, + message: 'Forbidden: account disabled', + href: 'https://help.ably.io/error/40300', + } as any); + + expect(error.code).to.equal(40300); + expect(error.statusCode).to.equal(403); + expect(error.message).to.equal('Forbidden: account disabled'); + expect(error.href).to.equal('https://help.ably.io/error/40300'); + }); +}); diff --git a/test/uts/rest/types/message_types.test.ts b/test/uts/rest/types/message_types.test.ts new file mode 100644 index 0000000000..ace625bc92 --- /dev/null +++ b/test/uts/rest/types/message_types.test.ts @@ -0,0 +1,208 @@ +/** + * UTS: Message Type Tests + * + * Spec points: TM1, TM2, TM3, TM4, TM5, TM2a, TM2b, TM2c, TM2d, TM2e, TM2f, TM2g, TM2h, TM2i + * Source: uts/test/rest/unit/types/message_types.md + */ + +import { expect } from 'chai'; +import { Ably } from '../../helpers'; + +const Message = Ably.Rest.Message; + +describe('uts/rest/types/message_types', function () { + /** + * TM2a - id attribute + */ + it('TM2a - id attribute', function () { + const msg = Message.fromValues({ id: 'msg-1' }); + expect(msg.id).to.equal('msg-1'); + }); + + /** + * TM2b - name attribute + */ + it('TM2b - name attribute', function () { + const msg = Message.fromValues({ name: 'test' }); + expect(msg.name).to.equal('test'); + }); + + /** + * TM2c - data attribute (string) + */ + it('TM2c - data attribute (string)', function () { + const msg = Message.fromValues({ data: 'hello' }); + expect(msg.data).to.equal('hello'); + }); + + /** + * TM2c - data attribute (object) + */ + it('TM2c - data attribute (object)', function () { + const msg = Message.fromValues({ data: { key: 'value' } }); + expect(msg.data).to.deep.equal({ key: 'value' }); + }); + + /** + * TM2d - clientId attribute + */ + it('TM2d - clientId attribute', function () { + const msg = Message.fromValues({ clientId: 'user-1' }); + expect(msg.clientId).to.equal('user-1'); + }); + + /** + * TM2e - connectionId attribute + */ + it('TM2e - connectionId attribute', function () { + const msg = Message.fromValues({ connectionId: 'conn-1' }); + expect(msg.connectionId).to.equal('conn-1'); + }); + + /** + * TM2f - timestamp attribute + */ + it('TM2f - timestamp attribute', function () { + const msg = Message.fromValues({ timestamp: 1234567890000 }); + expect(msg.timestamp).to.equal(1234567890000); + }); + + /** + * TM2g - encoding attribute + */ + it('TM2g - encoding attribute', function () { + const msg = Message.fromValues({ encoding: 'json' }); + expect(msg.encoding).to.equal('json'); + }); + + /** + * TM2h - extras attribute + */ + it('TM2h - extras attribute', function () { + const msg = Message.fromValues({ + extras: { push: { notification: { title: 'Hi' } } }, + }); + expect(msg.extras).to.deep.equal({ push: { notification: { title: 'Hi' } } }); + expect(msg.extras.push.notification.title).to.equal('Hi'); + }); + + /** + * TM2i - serial attribute + */ + it('TM2i - serial attribute', function () { + const msg = Message.fromValues({ serial: '01234567890:0' }); + expect(msg.serial).to.equal('01234567890:0'); + }); + + /** + * TM3 - deserialization from wire JSON via fromEncoded + */ + it('TM3 - deserialization from wire JSON', async function () { + const msg = await Message.fromEncoded({ + name: 'test', + data: 'hello', + id: 'msg-1', + clientId: 'sender-client', + connectionId: 'conn-456', + timestamp: 1234567890000, + extras: { headers: { 'x-custom': 'value' } }, + }); + + expect(msg.id).to.equal('msg-1'); + expect(msg.name).to.equal('test'); + expect(msg.data).to.equal('hello'); + expect(msg.clientId).to.equal('sender-client'); + expect(msg.connectionId).to.equal('conn-456'); + expect(msg.timestamp).to.equal(1234567890000); + expect(msg.extras).to.deep.equal({ headers: { 'x-custom': 'value' } }); + }); + + /** + * TM2 - null/missing attributes are undefined + * + * When a Message is constructed with only partial fields, the + * unspecified attributes should be undefined (not defaulted). + */ + it('TM2 - null/missing attributes are undefined', function () { + const msg = Message.fromValues({ name: 'test' }); + + expect(msg.name).to.equal('test'); + expect(msg.data).to.be.undefined; + expect(msg.clientId).to.be.undefined; + expect(msg.connectionId).to.be.undefined; + expect(msg.id).to.be.undefined; + expect(msg.timestamp).to.be.undefined; + }); + + /** + * TM3 - fromEncoded with all fields + * + * Verify that fromEncoded correctly deserializes a wire message + * containing all standard fields. + */ + it('TM3 - fromEncoded with all fields', async function () { + const msg = await Message.fromEncoded({ + id: 'id1', + name: 'test', + data: 'hello', + clientId: 'c1', + connectionId: 'conn1', + timestamp: 1700000000000, + encoding: null, + extras: { key: 'val' }, + }); + + expect(msg.id).to.equal('id1'); + expect(msg.name).to.equal('test'); + expect(msg.data).to.equal('hello'); + expect(msg.clientId).to.equal('c1'); + expect(msg.connectionId).to.equal('conn1'); + expect(msg.timestamp).to.equal(1700000000000); + expect(msg.extras).to.deep.equal({ key: 'val' }); + }); + + /** + * TM2 - binary data preserved + * + * When fromEncoded receives base64-encoded data with encoding 'base64', + * it should decode it to a binary type (Buffer or Uint8Array) and + * clear the encoding. + */ + it('TM2 - binary data preserved via base64 decoding', async function () { + const msg = await Message.fromEncoded({ + data: 'SGVsbG8=', + encoding: 'base64', + }); + + // After decoding, data should be a Buffer or Uint8Array + const isBinary = Buffer.isBuffer(msg.data) || msg.data instanceof Uint8Array; + expect(isBinary).to.be.true; + // Encoding should be consumed (null) after decode + expect(msg.encoding).to.be.null; + // Verify the decoded content is 'Hello' + const text = Buffer.from(msg.data).toString('utf8'); + expect(text).to.equal('Hello'); + }); + + /** + * TM4 - toJSON serialization + * + * If Message exposes a toJSON method, verify it returns an object + * with the expected name and data keys. + */ + it('TM4 - toJSON serialization', function () { + const msg = Message.fromValues({ name: 'event', data: 'payload' }); + + if (typeof (msg as any).toJSON === 'function') { + const json = (msg as any).toJSON(); + expect(json).to.have.property('name', 'event'); + expect(json).to.have.property('data', 'payload'); + } else { + // DEVIATION: ably-js Message may not expose toJSON directly. + // Verify JSON.stringify produces expected output instead. + const json = JSON.parse(JSON.stringify(msg)); + expect(json).to.have.property('name', 'event'); + expect(json).to.have.property('data', 'payload'); + } + }); +}); diff --git a/test/uts/rest/types/mutable_message_types.test.ts b/test/uts/rest/types/mutable_message_types.test.ts new file mode 100644 index 0000000000..8a7c0a5662 --- /dev/null +++ b/test/uts/rest/types/mutable_message_types.test.ts @@ -0,0 +1,237 @@ +/** + * UTS: Mutable Message Type Tests + * + * Spec points: TM2j, TM2r, TM2s, TM5, TM8, MOP, UDR, TAN + * Source: uts/test/rest/unit/types/mutable_message_types.md + */ + +import { expect } from 'chai'; +import { Ably } from '../../helpers'; + +describe('uts/rest/types/mutable_message_types', function () { + /** + * TM5 - MessageAction string values + * + * MessageAction enum has values: MESSAGE_CREATE (0), MESSAGE_UPDATE (1), + * MESSAGE_DELETE (2), META (3), MESSAGE_SUMMARY (4), MESSAGE_APPEND (5). + * In ably-js, application code uses string actions. + */ + it('TM5 - MessageAction string values', function () { + const actionStrings = [ + 'message.create', + 'message.update', + 'message.delete', + 'meta', + 'message.summary', + 'message.append', + ]; + + actionStrings.forEach(function (actionStr: any) { + const msg = Ably.Rest.Message.fromValues({ action: actionStr }); + expect(msg.action).to.equal(actionStr); + }); + }); + + /** + * TM5 - MessageAction numeric wire values + * + * Wire format uses numeric values (0-5). fromEncoded must decode + * these to their string equivalents. + */ + it('TM5 - MessageAction numeric wire values', async function () { + const wireToString = [ + [0, 'message.create'], + [1, 'message.update'], + [2, 'message.delete'], + [3, 'meta'], + [4, 'message.summary'], + [5, 'message.append'], + ]; + + for (const [wireValue, expectedString] of wireToString) { + const msg = await Ably.Rest.Message.fromEncoded({ + action: wireValue, + serial: 'test-serial', + name: 'test', + }); + expect(msg.action).to.equal(expectedString); + } + }); + + /** + * TM2j - action attribute + * + * Message has an action attribute of type MessageAction. + */ + it('TM2j - action attribute', function () { + const msg = Ably.Rest.Message.fromValues({ action: 'message.update' }); + expect(msg.action).to.equal('message.update'); + }); + + /** + * TM2r - serial attribute + * + * Message has a serial attribute: an opaque string that uniquely identifies the message. + */ + it('TM2r - serial attribute', function () { + const msg = Ably.Rest.Message.fromValues({ serial: 'abc:0' }); + expect(msg.serial).to.equal('abc:0'); + }); + + /** + * TM2s - version object fields + * + * Message.version is an object with serial, timestamp, clientId, description, metadata. + * When decoded from wire via fromEncoded, expandFields populates version defaults. + */ + it('TM2s - version object fields via fromEncoded', async function () { + const msg = await Ably.Rest.Message.fromEncoded({ + serial: 'msg-serial-1', + name: 'test', + data: 'hello', + version: { + serial: 'version-serial-1', + timestamp: 1700000001000, + clientId: 'editor-1', + description: 'fixed typo', + metadata: { reason: 'typo', tool: 'editor' }, + }, + }); + + expect(msg.version).to.exist; + expect(msg.version!.serial).to.equal('version-serial-1'); + expect(msg.version!.timestamp).to.equal(1700000001000); + expect(msg.version!.clientId).to.equal('editor-1'); + expect(msg.version!.description).to.equal('fixed typo'); + expect(msg.version!.metadata).to.deep.equal({ reason: 'typo', tool: 'editor' }); + }); + + /** + * TM2s1, TM2s2 - version defaults when not on wire + * + * If version is absent, SDK initializes it with serial from TM2r and timestamp from TM2f. + */ + it('TM2s1, TM2s2 - version defaults from serial and timestamp', async function () { + const msg = await Ably.Rest.Message.fromEncoded({ + serial: 'msg-serial-1', + timestamp: 1700000000000, + name: 'test', + data: 'hello', + }); + + expect(msg.version).to.exist; + // TM2s1: version.serial defaults to message serial + expect(msg.version!.serial).to.equal('msg-serial-1'); + // TM2s2: version.timestamp defaults to message timestamp + expect(msg.version!.timestamp).to.equal(1700000000000); + }); + + /** + * TM2u, TM8a - annotations defaults to empty + * + * If annotations not set on wire, SDK sets it to an empty MessageAnnotations with empty summary. + */ + it('TM2u, TM8a - annotations defaults to empty', async function () { + const msg = await Ably.Rest.Message.fromEncoded({ + serial: 'msg-serial-1', + name: 'test', + }); + + expect(msg.annotations).to.exist; + expect(msg.annotations!.summary).to.exist; + expect(Object.keys(msg.annotations!.summary)).to.have.lengthOf(0); + }); + + /** + * MOP2a-c - MessageOperation fields + * + * MessageOperation has clientId, description, metadata fields. + * In ably-js these are plain objects (no MessageOperation class). + */ + it('MOP2a-c - MessageOperation fields', function () { + const op = { + clientId: 'user-1', + description: 'edit description', + metadata: { reason: 'typo', tool: 'editor' }, + }; + + expect(op.clientId).to.equal('user-1'); + expect(op.description).to.equal('edit description'); + expect(op.metadata.reason).to.equal('typo'); + expect(op.metadata.tool).to.equal('editor'); + + // Empty operation + const emptyOp: any = {}; + expect(emptyOp.clientId).to.be.undefined; + expect(emptyOp.description).to.be.undefined; + expect(emptyOp.metadata).to.be.undefined; + }); + + /** + * UDR1, UDR2a - UpdateDeleteResult fields + * + * UpdateDeleteResult contains versionSerial field. + * In ably-js this is a plain object returned from update/delete operations. + */ + it('UDR1, UDR2a - UpdateDeleteResult versionSerial field', function () { + // Non-null versionSerial + const result1 = { versionSerial: 'version-serial-abc' }; + expect(result1.versionSerial).to.equal('version-serial-abc'); + + // Null versionSerial (message superseded) + const result2 = { versionSerial: null }; + expect(result2.versionSerial).to.be.null; + + // Missing versionSerial key + const result3: any = {}; + expect(result3.versionSerial).to.be.undefined; + }); + + /** + * TAN1, TAN2a-l - Annotation type and attributes + * + * Annotation represents an individual annotation event with id, action, clientId, + * name, type, data, count, serial, messageSerial, timestamp, extras fields. + * AnnotationAction: annotation.create (wire 0), annotation.delete (wire 1). + */ + it('TAN1, TAN2 - Annotation attributes via fromEncoded', async function () { + const ann = await Ably.Rest.Annotation.fromEncoded({ + id: 'ann-id-1', + action: 0, + clientId: 'user-1', + name: 'like', + count: 5, + data: 'thumbs-up', + timestamp: 1700000000000, + serial: 'ann-serial-1', + messageSerial: 'msg-serial-1', + type: 'com.example.reaction', + extras: { custom: 'metadata' }, + }); + + expect(ann.id).to.equal('ann-id-1'); + expect(ann.action).to.equal('annotation.create'); + expect(ann.clientId).to.equal('user-1'); + expect(ann.name).to.equal('like'); + expect(ann.count).to.equal(5); + expect(ann.data).to.equal('thumbs-up'); + expect(ann.timestamp).to.equal(1700000000000); + expect(ann.serial).to.equal('ann-serial-1'); + expect(ann.messageSerial).to.equal('msg-serial-1'); + expect(ann.type).to.equal('com.example.reaction'); + expect(ann.extras).to.deep.equal({ custom: 'metadata' }); + }); + + /** + * TAN2b - AnnotationAction values + * + * Wire 0 = annotation.create, wire 1 = annotation.delete. + */ + it('TAN2b - AnnotationAction wire values', async function () { + const create = await Ably.Rest.Annotation.fromEncoded({ action: 0, data: 'a' }); + expect(create.action).to.equal('annotation.create'); + + const del = await Ably.Rest.Annotation.fromEncoded({ action: 1, data: 'b' }); + expect(del.action).to.equal('annotation.delete'); + }); +}); diff --git a/test/uts/rest/types/options_types.test.ts b/test/uts/rest/types/options_types.test.ts new file mode 100644 index 0000000000..130b07abf9 --- /dev/null +++ b/test/uts/rest/types/options_types.test.ts @@ -0,0 +1,137 @@ +/** + * UTS: ClientOptions and AuthOptions Type Tests + * + * Spec points: TO1, TO2, TO3, AO1, AO2 + * Source: uts/test/rest/unit/types/options_types.md + */ + +import { expect } from 'chai'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../mock_http'; + +function simpleMock() { + return new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); +} + +describe('uts/rest/types/options_types', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * TO3 - ClientOptions defaults: tls + */ + it('TO3 - tls defaults to true', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + expect(client.options.tls).to.equal(true); + }); + + /** + * TO3 - ClientOptions defaults: useBinaryProtocol + */ + it('TO3 - useBinaryProtocol defaults to true', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + expect(client.options.useBinaryProtocol).to.equal(true); + }); + + /** + * TO3 - ClientOptions defaults: idempotentRestPublishing + */ + it('TO3 - idempotentRestPublishing defaults to true', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + expect(client.options.idempotentRestPublishing).to.equal(true); + }); + + /** + * TO3 - ClientOptions defaults: maxMessageSize + */ + it('TO3 - maxMessageSize defaults to 65536', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + expect(client.options.maxMessageSize).to.equal(65536); + }); + + /** + * TO3 - ClientOptions: setting values + */ + it('TO3 - setting custom option values', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + tls: false, + useBinaryProtocol: false, + idempotentRestPublishing: false, + }); + + expect(client.options.tls).to.equal(false); + expect(client.options.useBinaryProtocol).to.equal(false); + expect(client.options.idempotentRestPublishing).to.equal(false); + }); + + /** + * TO3 - ClientOptions: clientId accessible + */ + it('TO3 - clientId option', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + clientId: 'my-client', + }); + expect(client.auth.clientId).to.equal('my-client'); + }); + + /** + * TO3 - ClientOptions: key is parsed into keyName and keySecret + */ + it('TO3 - key parsed into keyName and keySecret', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + expect(client.options.keyName).to.equal('appId.keyId'); + expect(client.options.keySecret).to.equal('keySecret'); + }); + + /** + * TO - No auth options provided + */ + it('TO - error when no auth options provided', function () { + installMockHttp(simpleMock()); + try { + new Ably.Rest({}); + expect.fail('Expected constructor to throw'); + } catch (error) { + expect(error).to.exist; + } + }); + + /** + * AO2 - AuthOptions attributes via authUrl + */ + it('AO2 - authUrl and authMethod options', function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + authMethod: 'POST', + }); + expect(client.auth.authOptions.authUrl).to.equal('https://auth.example.com/token'); + expect(client.auth.authOptions.authMethod).to.equal('POST'); + }); + + /** + * AO2 - AuthOptions: authMethod defaults to GET + */ + it('AO2 - authMethod defaults to GET', function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + installMockHttp(simpleMock()); + const client = new Ably.Rest({ + authUrl: 'https://auth.example.com/token', + }); + expect(client.auth.authOptions.authMethod).to.equal('GET'); + }); +}); diff --git a/test/uts/rest/types/paginated_result.test.ts b/test/uts/rest/types/paginated_result.test.ts new file mode 100644 index 0000000000..b48655c927 --- /dev/null +++ b/test/uts/rest/types/paginated_result.test.ts @@ -0,0 +1,420 @@ +/** + * UTS: PaginatedResult Type Tests + * + * Spec points: TG1, TG2, TG3, TG4 + * Source: uts/test/rest/unit/types/paginated_result.md + * + * Tests pagination via channel.history(null) with mock HTTP responses. + * Link header URLs MUST use the `./word?params` format to match + * ably-js's getRelParams regex: /^\.\/(\w+)\?(.*)$/ + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/rest/types/paginated_result', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * TG1 - items attribute + * + * PaginatedResult must contain an items array with the result data. + * channel.history(null) returns PaginatedResult with correctly + * deserialized Message objects. + */ + it('TG1 - items attribute contains correct messages', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { id: 'item1', name: 'e1', data: 'd1' }, + { id: 'item2', name: 'e2', data: 'd2' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(2); + expect(result.items[0].name).to.equal('e1'); + expect(result.items[0].data).to.equal('d1'); + expect(result.items[1].name).to.equal('e2'); + expect(result.items[1].data).to.equal('d2'); + }); + + /** + * TG2 - hasNext() returns true when Link header contains rel="next" + * + * When the response includes a Link header with rel="next", + * hasNext() must return true and isLast() must return false. + */ + it('TG2 - hasNext true when Link header has rel="next"', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ id: 'item1' }], { + Link: '<./messages?cursor=abc123>; rel="next"', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.hasNext()).to.be.true; + expect(result.isLast()).to.be.false; + }); + + /** + * TG2 - hasNext() returns false when no Link header + * + * When the response has no Link header (or no rel="next"), + * hasNext() must return false and isLast() must return true. + */ + it('TG2 - hasNext false when no Link header', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ id: 'item1' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.hasNext()).to.be.false; + expect(result.isLast()).to.be.true; + }); + + /** + * TG3 - next() fetches the next page + * + * When the first page has a Link with rel="next", calling next() + * must fetch the second page and return its items. The second request + * must include the cursor parameter from the Link header. + */ + it('TG3 - next() fetches next page using Link header cursor', async function () { + const captured: any[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + + if (requestCount === 1) { + // First page — includes next link + req.respond_with( + 200, + [ + { id: 'page1-item1', name: 'a', data: 'x' }, + { id: 'page1-item2', name: 'b', data: 'y' }, + ], + { + Link: '<./messages?cursor=abc123>; rel="next"', + }, + ); + } else { + // Second page — last page, no next link + req.respond_with(200, [{ id: 'page2-item1', name: 'c', data: 'z' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.history(null); + expect(page1.items).to.have.length(2); + expect(page1.items[0].name).to.equal('a'); + expect(page1.hasNext()).to.be.true; + + const page2 = await page1.next(); + expect(page2).to.not.be.null; + expect(page2!.items).to.have.length(1); + expect(page2!.items[0].name).to.equal('c'); + expect(page2!.hasNext()).to.be.false; + + // Verify the next request included the cursor param + expect(captured).to.have.length(2); + expect(captured[1].url.searchParams.get('cursor')).to.equal('abc123'); + }); + + /** + * TG4 - first() returns the first page + * + * After navigating to page 2, calling first() must return page 1. + * The Link header must include rel="first" with ./messages? format. + */ + it('TG4 - first() returns first page', async function () { + const captured: any[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + + if (requestCount === 1) { + // First page — has next and first links + req.respond_with(200, [{ id: 'item1', name: 'first', data: 'one' }], { + Link: '<./messages?cursor=abc>; rel="next", <./messages?start=0>; rel="first"', + }); + } else if (requestCount === 2) { + // Second page — has first link only + req.respond_with(200, [{ id: 'item2', name: 'second', data: 'two' }], { + Link: '<./messages?start=0>; rel="first"', + }); + } else { + // First page again (via first()) + req.respond_with(200, [{ id: 'item1', name: 'first', data: 'one' }], { + Link: '<./messages?cursor=abc>; rel="next", <./messages?start=0>; rel="first"', + }); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.history(null); + expect(page1.items[0].name).to.equal('first'); + + const page2 = await page1.next(); + expect(page2!.items[0].name).to.equal('second'); + expect(page2!.hasFirst()).to.be.true; + + const firstPage = await page2!.first(); + expect(firstPage!.items[0].name).to.equal('first'); + expect(firstPage!.items[0].id).to.equal('item1'); + }); + + /** + * TG - Empty result + * + * An empty response body (empty array) must yield items.length=0, + * hasNext()=false, isLast()=true. + */ + it('TG - empty result has zero items and isLast true', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(0); + expect(result.hasNext()).to.be.false; + expect(result.isLast()).to.be.true; + }); + + /** + * TG - next() on last page returns null + * + * When isLast() is true, calling next() must return null + * (not an empty PaginatedResult). + */ + it('TG - next() on last page returns null', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ id: 'item1' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.isLast()).to.be.true; + + const nextResult = await result.next(); + expect(nextResult).to.be.null; + }); + + /** + * TG - Pagination preserves authentication + * + * Both the initial request and the next() pagination request must + * include the same Authorization header. + */ + it('TG - pagination preserves auth credentials', async function () { + const captured: any[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + + if (requestCount === 1) { + req.respond_with(200, [{ id: 'item1' }], { + Link: '<./messages?cursor=next>; rel="next"', + }); + } else { + req.respond_with(200, [{ id: 'item2' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.history(null); + await page1.next(); + + // Both requests must have authorization header + // (ably-js sends lowercase 'authorization') + expect(captured).to.have.length(2); + expect(captured[0].headers).to.have.property('authorization'); + expect(captured[1].headers).to.have.property('authorization'); + expect(captured[0].headers['authorization']).to.equal(captured[1].headers['authorization']); + }); + + /** + * TG - Pagination includes standard headers + * + * The next() pagination request must include standard Ably headers + * (X-Ably-Version and Ably-Agent). + */ + it('TG - pagination includes standard Ably headers', async function () { + const captured: any[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + + if (requestCount === 1) { + req.respond_with(200, [{ id: 'item1' }], { + Link: '<./messages?cursor=next>; rel="next"', + }); + } else { + req.respond_with(200, [{ id: 'item2' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.history(null); + await page1.next(); + + // Verify the pagination (second) request has standard headers + expect(captured).to.have.length(2); + const nextRequest = captured[1]; + expect(nextRequest.headers).to.have.property('X-Ably-Version'); + expect(nextRequest.headers).to.have.property('Ably-Agent'); + expect(nextRequest.headers['Ably-Agent']).to.match(/ably-js/); + }); + + /** + * TG - Error on next() propagates as exception + * + * When the server returns an error on the next page request, + * next() must throw with the appropriate error code and status. + */ + it('TG - error on next() throws with error code', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + + if (requestCount === 1) { + req.respond_with(200, [{ id: 'item1' }], { + Link: '<./messages?cursor=invalid>; rel="next"', + }); + } else { + req.respond_with(404, { + error: { + code: 40400, + statusCode: 404, + message: 'Not found', + }, + }); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.history(null); + expect(page1.hasNext()).to.be.true; + + try { + await page1.next(); + expect.fail('Expected next() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(404); + expect(error.code).to.equal(40400); + } + }); + + /** + * TG - multiple results on a page + * + * When the server returns multiple items on a single page, + * all items should be deserialized and accessible via result.items. + */ + it('TG - multiple results on a page', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { id: 'item1', name: 'e1', data: 'd1' }, + { id: 'item2', name: 'e2', data: 'd2' }, + { id: 'item3', name: 'e3', data: 'd3' }, + { id: 'item4', name: 'e4', data: 'd4' }, + { id: 'item5', name: 'e5', data: 'd5' }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(5); + expect(result.items[0].name).to.equal('e1'); + expect(result.items[0].data).to.equal('d1'); + expect(result.items[1].name).to.equal('e2'); + expect(result.items[2].name).to.equal('e3'); + expect(result.items[3].name).to.equal('e4'); + expect(result.items[4].name).to.equal('e5'); + expect(result.items[4].data).to.equal('d5'); + }); +}); diff --git a/test/uts/rest/types/presence_message_types.test.ts b/test/uts/rest/types/presence_message_types.test.ts new file mode 100644 index 0000000000..271d54a344 --- /dev/null +++ b/test/uts/rest/types/presence_message_types.test.ts @@ -0,0 +1,259 @@ +/** + * UTS: PresenceMessage Type Tests + * + * Spec points: TP1, TP2, TP3, TP3a-TP3i, TP4, TP5 + * Source: uts/test/rest/unit/types/presence_message_types.md + */ + +import { expect } from 'chai'; +import { Ably } from '../../helpers'; + +describe('uts/rest/types/presence_message_types', function () { + /** + * TP2 - PresenceAction values + * + * PresenceAction enum: absent (0), present (1), enter (2), leave (3), update (4). + * In ably-js, application code uses string actions. + */ + it('TP2 - PresenceAction values', function () { + const actionStrings = ['absent', 'present', 'enter', 'leave', 'update']; + + actionStrings.forEach(function (actionStr) { + const pm = Ably.Rest.PresenceMessage.fromValues({ action: actionStr }); + expect(pm.action).to.equal(actionStr); + }); + }); + + /** + * TP3a - id attribute + */ + it('TP3a - id attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ id: 'pm-1' }); + expect(pm.id).to.equal('pm-1'); + }); + + /** + * TP3b - action attribute + */ + it('TP3b - action attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ action: 'enter' }); + expect(pm.action).to.equal('enter'); + }); + + /** + * TP3c - clientId attribute + */ + it('TP3c - clientId attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ clientId: 'user-1' }); + expect(pm.clientId).to.equal('user-1'); + }); + + /** + * TP3d - connectionId attribute + */ + it('TP3d - connectionId attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ connectionId: 'conn-1' }); + expect(pm.connectionId).to.equal('conn-1'); + }); + + /** + * TP3e - data attribute (string) + */ + it('TP3e - data attribute (string)', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ data: 'hello' }); + expect(pm.data).to.equal('hello'); + }); + + /** + * TP3e - data attribute (object) + */ + it('TP3e - data attribute (object)', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ data: { key: 'val' } }); + expect(pm.data).to.deep.equal({ key: 'val' }); + }); + + /** + * TP3f - encoding attribute + */ + it('TP3f - encoding attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ encoding: 'json' }); + expect(pm.encoding).to.equal('json'); + }); + + /** + * TP3g - timestamp attribute + */ + it('TP3g - timestamp attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ timestamp: 1234567890000 }); + expect(pm.timestamp).to.equal(1234567890000); + }); + + /** + * TP3i - extras attribute + */ + it('TP3i - extras attribute', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ + extras: { headers: { 'x-custom': 'value' } }, + }); + expect(pm.extras.headers['x-custom']).to.equal('value'); + }); + + /** + * TP3h - memberKey combines connectionId and clientId + * + * Per spec, memberKey is a "string function that combines the connectionId + * and clientId ensuring multiple connected clients with the same clientId + * are uniquely identifiable." + */ + it('TP3h - memberKey format', function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const pm = Ably.Rest.PresenceMessage.fromValues({ + connectionId: 'conn-1', + clientId: 'client-1', + }); + + expect(typeof (pm as any).memberKey).to.equal('string'); + expect((pm as any).memberKey).to.equal('conn-1:client-1'); + + const pm2 = Ably.Rest.PresenceMessage.fromValues({ + connectionId: 'conn-2', + clientId: 'client-1', + }); + + expect((pm2 as any).memberKey).to.equal('conn-2:client-1'); + expect((pm as any).memberKey).to.not.equal((pm2 as any).memberKey); + }); + + /** + * TP3 - deserialization from wire format via fromEncoded + * + * Wire format uses numeric action (2 = enter). fromEncoded decodes to string action. + */ + it('TP3 - deserialization from wire via fromEncoded', async function () { + const pm = await Ably.Rest.PresenceMessage.fromEncoded({ + action: 2, + clientId: 'test', + data: 'hi', + }); + + expect(pm.action).to.equal('enter'); + expect(pm.clientId).to.equal('test'); + expect(pm.data).to.equal('hi'); + }); + + /** + * TP3 - wire numeric actions decode to correct strings + */ + it('TP3 - all wire action values decode correctly', async function () { + const expected = [ + { wire: 0, str: 'absent' }, + { wire: 1, str: 'present' }, + { wire: 2, str: 'enter' }, + { wire: 3, str: 'leave' }, + { wire: 4, str: 'update' }, + ]; + + for (const tc of expected) { + const pm = await Ably.Rest.PresenceMessage.fromEncoded({ + action: tc.wire, + clientId: 'user', + }); + expect(pm.action).to.equal(tc.str, 'wire action ' + tc.wire + ' should decode to ' + tc.str); + } + }); + + /** + * TP4 - fromEncoded with JSON-encoded data + * + * fromEncoded decodes data based on the encoding field. + */ + it('TP4 - fromEncoded decodes json-encoded data', async function () { + const pm = await Ably.Rest.PresenceMessage.fromEncoded({ + action: 2, + clientId: 'user-1', + data: '{"status":"online"}', + encoding: 'json', + }); + + expect(pm.data).to.deep.equal({ status: 'online' }); + // Encoding should be consumed after decoding + expect(pm.encoding).to.be.null; + }); + + /** + * TP4 - fromEncodedArray + * + * Decodes an array of wire-format presence messages. + */ + it('TP4 - fromEncodedArray', async function () { + const messages = await Ably.Rest.PresenceMessage.fromEncodedArray([ + { action: 2, clientId: 'alice', data: 'hello' }, + { action: 2, clientId: 'bob', data: 'world' }, + ]); + + expect(messages).to.have.lengthOf(2); + expect(messages[0].clientId).to.equal('alice'); + expect(messages[0].data).to.equal('hello'); + expect(messages[1].clientId).to.equal('bob'); + expect(messages[1].data).to.equal('world'); + }); + + /** + * TP3 - null/missing attributes are undefined + * + * When fromEncoded receives a minimal presence message (only action), + * unspecified attributes should be null or undefined. + */ + it('TP3 - null/missing attributes are undefined', async function () { + const pm = await Ably.Rest.PresenceMessage.fromEncoded({ action: 1 }); + + expect(pm.action).to.equal('present'); + // clientId, connectionId, data should be null or undefined + expect(pm.clientId).to.satisfy((v: any) => v === null || v === undefined); + expect(pm.connectionId).to.satisfy((v: any) => v === null || v === undefined); + expect(pm.data).to.satisfy((v: any) => v === null || v === undefined); + }); + + /** + * TP3 - timestamp as number + * + * When fromEncoded receives a presence message with a numeric timestamp, + * it should be preserved as-is. + */ + it('TP3 - timestamp as number', async function () { + const pm = await Ably.Rest.PresenceMessage.fromEncoded({ + action: 1, + timestamp: 1700000000000, + }); + + expect(pm.action).to.equal('present'); + expect(pm.timestamp).to.equal(1700000000000); + }); + + /** + * TP - presence message with data exists as complete object + * + * Construct a PresenceMessage with data and verify it has all + * the expected properties of a complete presence message. + */ + it('TP - presence message with data is a complete object', function () { + const pm = Ably.Rest.PresenceMessage.fromValues({ + action: 'enter', + clientId: 'user-1', + connectionId: 'conn-1', + data: { status: 'online', role: 'admin' }, + timestamp: 1700000000000, + id: 'pm-full', + encoding: null, + }); + + expect(pm).to.be.an('object'); + expect(pm.action).to.equal('enter'); + expect(pm.clientId).to.equal('user-1'); + expect(pm.connectionId).to.equal('conn-1'); + expect(pm.data).to.deep.equal({ status: 'online', role: 'admin' }); + expect(pm.timestamp).to.equal(1700000000000); + expect(pm.id).to.equal('pm-full'); + }); +}); diff --git a/test/uts/rest/types/token_types.test.ts b/test/uts/rest/types/token_types.test.ts new file mode 100644 index 0000000000..7f11a63654 --- /dev/null +++ b/test/uts/rest/types/token_types.test.ts @@ -0,0 +1,332 @@ +/** + * UTS: TokenDetails, TokenParams, and TokenRequest Type Tests + * + * Spec points: TD1, TD2, TD3, TD4, TD5, TK1, TK2, TK3, TK4, TK5, TK6, TE1, TE2, TE3, TE4, TE5, TE6 + * Source: uts/test/rest/unit/types/token_types.md + */ + +import { expect } from 'chai'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../mock_http'; + +function simpleMock() { + return new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, []), + }); +} + +describe('uts/rest/types/token_types', function () { + afterEach(function () { + restoreAll(); + }); + + // --- TD1-TD5: TokenDetails attributes --- + + /** + * TD1-TD5 - TokenDetails attributes are accessible via authCallback + * + * TokenDetails is a plain object in ably-js. We verify all fields + * (token, expires, issued, capability, clientId) are accessible + * on client.auth.tokenDetails after authorize(). + */ + it('TD1-TD5 - TokenDetails attributes from authCallback', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params, callback) { + callback(null, { + token: 'test-token', + expires: 1234567890000, + issued: 1234567800000, + capability: '{"*":["*"]}', + clientId: 'my-client', + }); + }, + }); + + await client.auth.authorize(); + + // TD1 - token attribute + expect(client.auth.tokenDetails!.token).to.equal('test-token'); + // TD2 - expires attribute (milliseconds since epoch) + expect(client.auth.tokenDetails!.expires).to.equal(1234567890000); + // TD3 - issued attribute (milliseconds since epoch) + expect(client.auth.tokenDetails!.issued).to.equal(1234567800000); + // TD4 - capability attribute (JSON string) + expect(client.auth.tokenDetails!.capability).to.equal('{"*":["*"]}'); + // TD5 - clientId attribute + expect(client.auth.tokenDetails!.clientId).to.equal('my-client'); + }); + + // --- TK1-TK6: TokenParams attributes via createTokenRequest --- + + /** + * TK1-TK6 - TokenParams attributes reflected in createTokenRequest result + * + * createTokenRequest() accepts TokenParams and returns a signed + * TokenRequest containing the supplied values. + */ + it('TK1-TK6 - TokenParams attributes via createTokenRequest', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest( + { + ttl: 3600000, + capability: '{"*":["subscribe"]}', + clientId: 'param-client', + timestamp: 1234567890000, + nonce: 'custom-nonce', + }, + null, + ); + + // TK1 - ttl + expect(tokenRequest.ttl).to.equal(3600000); + // TK2 - capability + expect(tokenRequest.capability).to.equal('{"*":["subscribe"]}'); + // TK3 - clientId + expect(tokenRequest.clientId).to.equal('param-client'); + // TK4 - timestamp + expect(tokenRequest.timestamp).to.equal(1234567890000); + // TK5 - nonce + expect(tokenRequest.nonce).to.equal('custom-nonce'); + }); + + /** + * TK1 - TTL defaults to null when not specified + */ + it('TK1 - TTL defaults to null when not specified', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest({}, null); + + expect(tokenRequest.ttl).to.satisfy((v: any) => v === null || v === undefined || v === ''); + }); + + /** + * TK2 - Capability defaults to null when not specified + */ + it('TK2 - Capability defaults to null when not specified', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest({}, null); + + expect(tokenRequest.capability).to.satisfy((v: any) => v === null || v === undefined || v === ''); + }); + + // --- TE1-TE6: TokenRequest attributes --- + + /** + * TE1-TE6 - TokenRequest has all required attributes + * + * createTokenRequest() returns a signed TokenRequest with keyName, + * ttl, capability, clientId, timestamp, nonce, and mac. + */ + it('TE1-TE6 - TokenRequest attributes from createTokenRequest', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest( + { + ttl: 3600000, + capability: '{"*":["*"]}', + clientId: 'request-client', + timestamp: 1234567890000, + nonce: 'unique-nonce', + }, + null, + ); + + // TE1 - keyName (derived from the API key) + expect(tokenRequest.keyName).to.equal('appId.keyId'); + // TE2 - ttl + expect(tokenRequest.ttl).to.equal(3600000); + // TE3 - capability + expect(tokenRequest.capability).to.equal('{"*":["*"]}'); + // TE4 - clientId + expect(tokenRequest.clientId).to.equal('request-client'); + // TE5 - timestamp + expect(tokenRequest.timestamp).to.equal(1234567890000); + // TE6 - nonce + expect(tokenRequest.nonce).to.equal('unique-nonce'); + }); + + /** + * TE - TokenRequest has mac (signature) + * + * The mac field is a non-empty string generated by signing + * the token request parameters with the key secret. + */ + it('TE - TokenRequest has mac (signature)', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest( + { + ttl: 3600000, + capability: '{"*":["*"]}', + timestamp: 1234567890000, + nonce: 'nonce-for-mac', + }, + null, + ); + + expect(tokenRequest.mac).to.be.a('string'); + expect(tokenRequest.mac.length).to.be.greaterThan(0); + }); + + /** + * TE - TokenRequest to JSON round-trip + * + * JSON.stringify the TokenRequest and parse it back; + * verify all fields survive the round-trip. + */ + it('TE - TokenRequest JSON round-trip', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest( + { + ttl: 3600000, + capability: '{"*":["*"]}', + clientId: 'json-client', + timestamp: 1234567890000, + nonce: 'json-nonce', + }, + null, + ); + + const json = JSON.stringify(tokenRequest); + const parsed = JSON.parse(json); + + expect(parsed.keyName).to.equal('appId.keyId'); + expect(parsed.ttl).to.equal(3600000); + expect(parsed.capability).to.equal('{"*":["*"]}'); + expect(parsed.clientId).to.equal('json-client'); + expect(parsed.timestamp).to.equal(1234567890000); + expect(parsed.nonce).to.equal('json-nonce'); + expect(parsed.mac).to.be.a('string'); + expect(parsed.mac.length).to.be.greaterThan(0); + }); + + /** + * TD - TokenDetails from authorize() + * + * authorize() returns TokenDetails; verify it has token, expires, + * and issued fields. + */ + it('TD - TokenDetails from authorize()', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + if (req.path.match(/\/keys\/.*\/requestToken/)) { + req.respond_with(200, { + token: 'authorized-token', + expires: Date.now() + 3600000, + issued: Date.now(), + keyName: 'appId.keyId', + }); + } else { + req.respond_with(200, []); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + const tokenDetails = await client.auth.authorize(); + + expect(tokenDetails.token).to.equal('authorized-token'); + expect(tokenDetails.expires).to.be.a('number'); + expect(tokenDetails.issued).to.be.a('number'); + }); + + /** + * TE1 - keyName derived from API key + * + * Verify keyName is the portion of the key before the colon + * (appId.keyId), not the full key string. + */ + it('TE1 - keyName derived from API key', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'myApp.myKey:mySecret' }); + + const tokenRequest = await client.auth.createTokenRequest(null, null); + + expect(tokenRequest.keyName).to.equal('myApp.myKey'); + }); + + /** + * TE5 - timestamp auto-generated when not specified + * + * When no timestamp is provided, createTokenRequest generates one + * automatically. It should be a recent timestamp (within last minute). + */ + it('TE5 - timestamp auto-generated when not specified', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const before = Date.now(); + const tokenRequest = await client.auth.createTokenRequest(null, null); + const after = Date.now(); + + expect(tokenRequest.timestamp).to.be.a('number'); + expect(tokenRequest.timestamp).to.be.at.least(before - 1000); + expect(tokenRequest.timestamp).to.be.at.most(after + 1000); + }); + + /** + * TE6 - nonce auto-generated when not specified + * + * When no nonce is provided, createTokenRequest generates one + * automatically. It should be a non-empty string. + */ + it('TE6 - nonce auto-generated when not specified', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest(null, null); + + expect(tokenRequest.nonce).to.be.a('string'); + expect(tokenRequest.nonce.length).to.be.greaterThan(0); + }); + + /** + * TD - TokenDetails from token string + * + * When a Rest client is instantiated with a plain token string, + * the token should be accessible via client.auth.tokenDetails. + */ + it('TD - TokenDetails from token string', async function () { + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ token: 'test-token' }); + + // Accessing tokenDetails should reflect the token provided + expect(client.auth.tokenDetails!.token).to.equal('test-token'); + }); + + /** + * TE - createTokenRequest preserves custom ttl + * + * When a custom TTL (e.g. 7200000 = 2 hours) is specified in + * TokenParams, createTokenRequest must preserve it in the result. + */ + it('TE - createTokenRequest preserves custom ttl', async function () { + installMockHttp(simpleMock()); + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + + const tokenRequest = await client.auth.createTokenRequest( + { + ttl: 7200000, + }, + null, + ); + + expect(tokenRequest.ttl).to.equal(7200000); + }); +});