diff --git a/packages/@tinacms/auth/jest.config.js b/packages/@tinacms/auth/jest.config.js new file mode 100644 index 0000000000..00a600d25b --- /dev/null +++ b/packages/@tinacms/auth/jest.config.js @@ -0,0 +1,19 @@ +import jestRunnerConfig from '@tinacms/scripts/dist/jest-runner.js'; + +export default { + ...jestRunnerConfig, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, + coverageThreshold: { + global: { + lines: 80, + functions: 80, + }, + }, +}; diff --git a/packages/@tinacms/auth/package.json b/packages/@tinacms/auth/package.json index dd1f89fe1d..ee4d5eab39 100644 --- a/packages/@tinacms/auth/package.json +++ b/packages/@tinacms/auth/package.json @@ -12,7 +12,7 @@ "scripts": { "types": "pnpm tsc", "build": "tinacms-scripts build", - "test": "jest --passWithNoTests" + "test": "jest --config jest.config.js" }, "publishConfig": { "registry": "https://registry.npmjs.org" @@ -23,10 +23,12 @@ }, "devDependencies": { "@tinacms/scripts": "workspace:*", + "@types/jest": "^29.5.14", "jest": "catalog:", "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1", + "ts-jest": "catalog:", "typescript": "^5.7.3" }, "dependencies": {} diff --git a/packages/@tinacms/auth/src/index.test.ts b/packages/@tinacms/auth/src/index.test.ts new file mode 100644 index 0000000000..ea9e41f165 --- /dev/null +++ b/packages/@tinacms/auth/src/index.test.ts @@ -0,0 +1,150 @@ +import { + TinaCloudBackendAuthProvider, + isAuthorized, + isUserAuthorized, +} from './index'; +import type { NextApiRequest } from 'next'; +import type { ServerResponse } from 'http'; + +const mockUser = { + id: 'user-123', + email: 'test@example.com', + verified: true, + role: 'admin' as const, + enabled: true, + fullName: 'Test User', +}; + +let fetchSpy: jest.SpyInstance; + +beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('isUserAuthorized', () => { + it('returns undefined when TinaCloud responds with a non-ok status', async () => { + // Simulate TinaCloud rejecting the token (expired, malformed, etc.) + fetchSpy.mockResolvedValueOnce({ + ok: false, + }); + + const result = await isUserAuthorized({ + clientID: 'my-client-id', + token: 'Bearer expired-token', + }); + + // A rejected token must never return a user + expect(result).toBeUndefined(); + }); + + it('re-throws the original error when a network error occurs', async () => { + const networkError = new Error('Network failure'); + fetchSpy.mockRejectedValueOnce(networkError); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + await expect( + isUserAuthorized({ clientID: 'my-client-id', token: 'Bearer some-token' }) + ).rejects.toThrow(networkError); + }); + + it('returns a user when TinaCloud responds with a valid token', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockUser, + }); + + const result = await isUserAuthorized({ + clientID: 'my-client-id', + token: 'Bearer valid-token', + }); + + expect(result).toEqual(mockUser); + }); +}); + +describe('isAuthorized', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('returns undefined when clientID is missing from the request', async () => { + const req = { + query: {}, + headers: { authorization: 'Bearer some-token' }, + } as unknown as NextApiRequest; + + const result = await isAuthorized(req); + + // Should never reach TinaCloud — fetch must not be called + expect(result).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('clientID') + ); + }); + + it('returns undefined when authorization header is missing from the request', async () => { + const req = { + query: { clientID: 'my-client-id' }, + headers: {}, + } as unknown as NextApiRequest; + + const result = await isAuthorized(req); + + // Should never reach TinaCloud — fetch must not be called + expect(result).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('authorization') + ); + }); +}); + +describe('TinaCloudBackendAuthProvider', () => { + it('returns isAuthorized: false when the user is not verified', async () => { + // Simulate TinaCloud returning a user that exists but has not verified their email + const unverifiedUser = { ...mockUser, verified: false }; + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => unverifiedUser, + }); + + const provider = TinaCloudBackendAuthProvider(); + const req = { + query: { clientID: 'my-client-id' }, + headers: { authorization: 'Bearer some-token' }, + } as unknown as NextApiRequest; + + const result = await provider.isAuthorized(req, {} as ServerResponse); + + // An unverified user must never be granted access + expect(result).toEqual({ + isAuthorized: false, + errorCode: 401, + errorMessage: 'Unauthorized', + }); + }); + + it('returns isAuthorized: true when the user is verified', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockUser, + }); + + const provider = TinaCloudBackendAuthProvider(); + const req = { + query: { clientID: 'my-client-id' }, + headers: { authorization: 'Bearer valid-token' }, + } as unknown as NextApiRequest; + + const result = await provider.isAuthorized(req, {} as ServerResponse); + + expect(result).toEqual({ isAuthorized: true }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 528700bd6f..9b2a2af6d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1529,6 +1529,9 @@ importers: '@tinacms/scripts': specifier: workspace:* version: link:../scripts + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 jest: specifier: 'catalog:' version: 29.7.0(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.1.0)(typescript@5.9.3)) @@ -1541,6 +1544,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + ts-jest: + specifier: 'catalog:' + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.1.0)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -1552,7 +1558,7 @@ importers: version: 2.6.8(graphql@15.8.0) '@graphql-codegen/plugin-helpers': specifier: latest - version: 6.2.0(graphql@15.8.0) + version: 6.2.1(graphql@15.8.0) '@graphql-codegen/typescript': specifier: 'catalog:' version: 4.1.6(graphql@15.8.0) @@ -4018,10 +4024,12 @@ packages: '@clerk/types@3.57.0': resolution: {integrity: sha512-i+XqgqRbcMn5gMwWGiA3W6lCTSDYdonDAgoJgNyRemQg7Zesnbd4OoJZfqyW6vN3wvD9Bl+gMb7gQ3VquNWUOA==} engines: {node: '>=14'} + deprecated: 'This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' '@clerk/types@3.65.5': resolution: {integrity: sha512-RGO8v2a52Ybo1jwVj42UWT8VKyxAk/qOxrkA3VNIYBNEajPSmZNa9r9MTgqSgZRyz1XTlQHdVb7UK7q78yAGfA==} engines: {node: '>=14'} + deprecated: 'This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' '@codemirror/language@6.0.0': resolution: {integrity: sha512-rtjk5ifyMzOna1c7PBu7J1VCt0PvA5wy3o8eMVnxMKb7z8KA7JFecvD04dSn14vj/bBaAbqRsGed5OjtofEnLA==} @@ -5149,8 +5157,8 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - '@graphql-codegen/plugin-helpers@6.2.0': - resolution: {integrity: sha512-TKm0Q0+wRlg354Qt3PyXc+sy6dCKxmNofBsgmHoFZNVHtzMQSSgNT+rUWdwBwObQ9bFHiUVsDIv8QqxKMiKmpw==} + '@graphql-codegen/plugin-helpers@6.2.1': + resolution: {integrity: sha512-shRr26TfVZ6KFBjzRYUj02gLNh6yaECz9gTGgI6riANw5sSH9PONwTsBRYkEgU+6IXiL7VQeCumahvxSGFbRlQ==} engines: {node: '>=16'} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -8944,6 +8952,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -10903,11 +10912,11 @@ packages: glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -18914,14 +18923,13 @@ snapshots: lodash: 4.17.21 tslib: 2.6.3 - '@graphql-codegen/plugin-helpers@6.2.0(graphql@15.8.0)': + '@graphql-codegen/plugin-helpers@6.2.1(graphql@15.8.0)': dependencies: '@graphql-tools/utils': 11.0.0(graphql@15.8.0) change-case-all: 1.0.15 common-tags: 1.8.2 graphql: 15.8.0 import-from: 4.0.0 - lodash: 4.17.21 tslib: 2.6.3 '@graphql-codegen/schema-ast@4.1.0(graphql@15.8.0)': @@ -25006,7 +25014,7 @@ snapshots: '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) @@ -25038,33 +25046,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.24.0))(eslint@8.24.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 8.24.0 + eslint: 9.39.2(jiti@2.6.1) get-tsconfig: 4.13.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.24.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.24.0))(eslint@8.24.0))(eslint@8.24.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.24.0))(eslint@8.24.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 8.24.0 get-tsconfig: 4.13.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.24.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.24.0))(eslint@8.24.0))(eslint@8.24.0) transitivePeerDependencies: - supports-color @@ -25090,14 +25098,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -25170,7 +25178,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3