Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/@tinacms/auth/jest.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
},
};
4 changes: 3 additions & 1 deletion packages/@tinacms/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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": {}
Expand Down
150 changes: 150 additions & 0 deletions packages/@tinacms/auth/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
42 changes: 25 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading