Skip to content
Open
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
3 changes: 3 additions & 0 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"backend"
],
"dependencies": {
"@constructive-io/csrf": "workspace:^",
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@constructive-io/s3-utils": "workspace:^",
Expand Down Expand Up @@ -79,12 +80,14 @@
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.1009.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/graphql-upload": "^8.0.12",
"@types/multer": "^2.1.0",
"@types/pg": "^8.18.0",
"@types/request-ip": "^0.0.41",
"cookie-parser": "^1.4.7",
"graphile-test": "workspace:*",
"makage": "^0.3.0",
"nodemon": "^3.1.14",
Expand Down
88 changes: 88 additions & 0 deletions graphql/server/src/middleware/__tests__/auth-device-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Request, Response, NextFunction } from 'express';
import { DEVICE_TOKEN_COOKIE_NAME } from '../cookie';

/**
* Test the device token reading functionality in auth middleware.
*
* The actual createAuthenticateMiddleware requires database connections,
* so we test the device token parsing logic in isolation.
*/

/** Cookie parsing function - mirrors the implementation in auth.ts */
const parseCookieToken = (req: Request, cookieName: string): string | undefined => {
const header = req.headers.cookie;
if (!header) return undefined;
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
};

describe('auth middleware device token handling', () => {
const createMockRequest = (cookies?: string): Partial<Request> => ({
headers: cookies ? { cookie: cookies } : {},
});

describe('device token cookie parsing', () => {
it('should extract device token from cookie header', () => {
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=device-abc123`);
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
expect(deviceToken).toBe('device-abc123');
});

it('should return undefined when device token cookie is not present', () => {
const req = createMockRequest('other_cookie=value');
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
expect(deviceToken).toBeUndefined();
});

it('should return undefined when no cookies are present', () => {
const req = createMockRequest();
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
expect(deviceToken).toBeUndefined();
});

it('should handle multiple cookies and extract device token', () => {
const req = createMockRequest(
`session=abc; ${DEVICE_TOKEN_COOKIE_NAME}=device-xyz789; csrf=token123`
);
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
expect(deviceToken).toBe('device-xyz789');
});

it('should decode URL-encoded device token values', () => {
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=device%2Ftoken%3D123`);
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
expect(deviceToken).toBe('device/token=123');
});

it('should handle device token with special characters', () => {
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=abc-123_XYZ.test`);
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
expect(deviceToken).toBe('abc-123_XYZ.test');
});
});

describe('device token attachment to request', () => {
it('should set deviceToken on request when cookie is present', () => {
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=device-token-value`) as Request;

// Simulate what auth middleware does
const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
if (deviceToken) {
(req as any).deviceToken = deviceToken;
}

expect((req as any).deviceToken).toBe('device-token-value');
});

it('should not set deviceToken when cookie is absent', () => {
const req = createMockRequest('other=value') as Request;

const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
if (deviceToken) {
(req as any).deviceToken = deviceToken;
}

expect((req as any).deviceToken).toBeUndefined();
});
});
});
279 changes: 279 additions & 0 deletions graphql/server/src/middleware/__tests__/cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import type { Request, Response } from 'express';
import {
SESSION_COOKIE_NAME,
DEVICE_TOKEN_COOKIE_NAME,
getSessionCookieConfig,
getDeviceTokenCookieConfig,
setSessionCookie,
clearSessionCookie,
setDeviceTokenCookie,
clearDeviceTokenCookie,
parseCookieValue,
getDeviceTokenFromRequest,
getSessionTokenFromRequest,
} from '../cookie';
import type { AuthSettings } from '../../types';

describe('cookie utilities', () => {
describe('getSessionCookieConfig', () => {
it('returns default config when no authSettings provided', () => {
const config = getSessionCookieConfig();
expect(config).toEqual({
secure: false, // NODE_ENV is 'test'
sameSite: 'lax',
domain: undefined,
httpOnly: true,
maxAge: 86400,
path: '/',
});
});

it('uses authSettings values when provided', () => {
const authSettings: AuthSettings = {
cookieSecure: true,
cookieSamesite: 'strict',
cookieDomain: '.example.com',
cookieHttponly: false,
cookieMaxAge: '3600',
cookiePath: '/api',
};
const config = getSessionCookieConfig(authSettings);
expect(config).toEqual({
secure: true,
sameSite: 'strict',
domain: '.example.com',
httpOnly: false,
maxAge: 3600,
path: '/api',
});
});

it('uses rememberMeDuration when rememberMe is true', () => {
const authSettings: AuthSettings = {
cookieMaxAge: '3600',
rememberMeDuration: '2592000', // 30 days
};
const config = getSessionCookieConfig(authSettings, true);
expect(config.maxAge).toBe(2592000);
});

it('uses cookieMaxAge when rememberMe is false', () => {
const authSettings: AuthSettings = {
cookieMaxAge: '3600',
rememberMeDuration: '2592000',
};
const config = getSessionCookieConfig(authSettings, false);
expect(config.maxAge).toBe(3600);
});

it('falls back to cookieMaxAge when rememberMeDuration is not set', () => {
const authSettings: AuthSettings = {
cookieMaxAge: '7200',
};
const config = getSessionCookieConfig(authSettings, true);
expect(config.maxAge).toBe(7200);
});
});

describe('getDeviceTokenCookieConfig', () => {
it('returns config with 90 day maxAge', () => {
const config = getDeviceTokenCookieConfig();
expect(config.maxAge).toBe(90 * 24 * 60 * 60);
expect(config.httpOnly).toBe(true);
});

it('uses authSettings for other cookie options', () => {
const authSettings: AuthSettings = {
cookieSecure: true,
cookieDomain: '.example.com',
};
const config = getDeviceTokenCookieConfig(authSettings);
expect(config.secure).toBe(true);
expect(config.domain).toBe('.example.com');
});
});

describe('setSessionCookie', () => {
it('sets cookie with correct options', () => {
const mockRes = {
cookie: jest.fn(),
} as unknown as Response;

const config = {
secure: true,
sameSite: 'lax' as const,
domain: '.example.com',
httpOnly: true,
maxAge: 3600,
path: '/',
};

setSessionCookie(mockRes, 'test-token', config);

expect(mockRes.cookie).toHaveBeenCalledWith(
SESSION_COOKIE_NAME,
'test-token',
{
secure: true,
sameSite: 'lax',
domain: '.example.com',
httpOnly: true,
maxAge: 3600000, // converted to milliseconds
path: '/',
}
);
});
});

describe('clearSessionCookie', () => {
it('clears cookie with correct options', () => {
const mockRes = {
clearCookie: jest.fn(),
} as unknown as Response;

const config = {
secure: true,
sameSite: 'lax' as const,
domain: '.example.com',
httpOnly: true,
maxAge: 3600,
path: '/',
};

clearSessionCookie(mockRes, config);

expect(mockRes.clearCookie).toHaveBeenCalledWith(
SESSION_COOKIE_NAME,
{
secure: true,
sameSite: 'lax',
domain: '.example.com',
httpOnly: true,
path: '/',
}
);
});
});

describe('setDeviceTokenCookie', () => {
it('sets device token cookie', () => {
const mockRes = {
cookie: jest.fn(),
} as unknown as Response;

const config: Parameters<typeof setDeviceTokenCookie>[2] = {
secure: true,
sameSite: 'lax',
httpOnly: true,
maxAge: 7776000,
path: '/',
};

setDeviceTokenCookie(mockRes, 'device-123', config);

expect(mockRes.cookie).toHaveBeenCalledWith(
DEVICE_TOKEN_COOKIE_NAME,
'device-123',
expect.objectContaining({
maxAge: 7776000000,
})
);
});
});

describe('clearDeviceTokenCookie', () => {
it('clears device token cookie', () => {
const mockRes = {
clearCookie: jest.fn(),
} as unknown as Response;

const config: Parameters<typeof clearDeviceTokenCookie>[1] = {
secure: false,
sameSite: 'lax',
httpOnly: true,
maxAge: 7776000,
path: '/',
};

clearDeviceTokenCookie(mockRes, config);

expect(mockRes.clearCookie).toHaveBeenCalledWith(
DEVICE_TOKEN_COOKIE_NAME,
expect.objectContaining({
httpOnly: true,
path: '/',
})
);
});
});

describe('parseCookieValue', () => {
it('parses cookie value from header', () => {
const mockReq = {
headers: {
cookie: 'foo=bar; constructive_session=test-token; baz=qux',
},
} as unknown as Request;

const value = parseCookieValue(mockReq, 'constructive_session');
expect(value).toBe('test-token');
});

it('returns undefined when cookie not found', () => {
const mockReq = {
headers: {
cookie: 'foo=bar',
},
} as unknown as Request;

const value = parseCookieValue(mockReq, 'constructive_session');
expect(value).toBeUndefined();
});

it('returns undefined when no cookie header', () => {
const mockReq = {
headers: {},
} as unknown as Request;

const value = parseCookieValue(mockReq, 'constructive_session');
expect(value).toBeUndefined();
});

it('decodes URL-encoded cookie values', () => {
const mockReq = {
headers: {
cookie: 'token=hello%20world',
},
} as unknown as Request;

const value = parseCookieValue(mockReq, 'token');
expect(value).toBe('hello world');
});
});

describe('getDeviceTokenFromRequest', () => {
it('extracts device token from cookie', () => {
const mockReq = {
headers: {
cookie: `${DEVICE_TOKEN_COOKIE_NAME}=device-abc123`,
},
} as unknown as Request;

const token = getDeviceTokenFromRequest(mockReq);
expect(token).toBe('device-abc123');
});
});

describe('getSessionTokenFromRequest', () => {
it('extracts session token from cookie', () => {
const mockReq = {
headers: {
cookie: `${SESSION_COOKIE_NAME}=session-xyz789`,
},
} as unknown as Request;

const token = getSessionTokenFromRequest(mockReq);
expect(token).toBe('session-xyz789');
});
});
});
Loading
Loading