super-productivity/packages/super-sync-server/tests/middleware.spec.ts
Johannes Millan 7070fde51e
Pr 6741 (#6743)
* fix(build): fix tag resolution and add error handling in bump-android-version

The previous two-tag approach (`currentTag` → `prevTag`) would resolve the
wrong changelog range during `npm version` since the new tag doesn't exist
yet. Simplified to a single `lastTag...HEAD` range which correctly captures
all commits since the last release.

Added try-catch with fallback to last 20 commits when no tags exist, and
a fallback message for empty changelogs.

* fix(sync): differentiate auth error messages and clarify token revocation

- Rename server dashboard "Refresh Token" to "Revoke & Replace Token"
  with explicit warning that ALL devices will be disconnected
- Return rejection reason in 401 responses (revoked, expired, etc.)
  via discriminated union TokenVerificationResult type
- Show different client error messages for server-side token rejection
  vs missing local credentials to aid user diagnosis
- Extract server error reason from JSON response body in AuthFailSPError

Closes #6597

* refactor(sync): use generic auth reason and improve test readability

- Replace 'User not found' and 'Account not verified' with generic
  'Account unavailable' to avoid leaking account state in API responses
- Extract long reason strings to constants in middleware.spec.ts
2026-03-05 21:17:59 +01:00

146 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { FastifyRequest, FastifyReply } from 'fastify';
// Mock verifyToken from auth module
const mockVerifyToken = vi.fn();
vi.mock('../src/auth', () => ({
verifyToken: (...args: unknown[]) => mockVerifyToken(...args),
}));
import { authenticate, getAuthUser } from '../src/middleware';
/**
* Create a mock Fastify request with optional authorization header.
*/
const createMockRequest = (authHeader?: string): FastifyRequest => {
const req = {
headers: {} as Record<string, string | undefined>,
user: undefined,
} as unknown as FastifyRequest;
if (authHeader !== undefined) {
req.headers.authorization = authHeader;
}
return req;
};
/**
* Create a mock Fastify reply that captures status code and response body.
*/
const createMockReply = (): FastifyReply & { _statusCode: number; _body: unknown } => {
const reply = {
_statusCode: 0,
_body: undefined as unknown,
code(status: number) {
reply._statusCode = status;
return reply;
},
send(body: unknown) {
reply._body = body;
return reply;
},
} as unknown as FastifyReply & { _statusCode: number; _body: unknown };
return reply;
};
describe('authenticate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return 401 when Authorization header is missing', async () => {
const req = createMockRequest();
const reply = createMockReply();
await authenticate(req, reply);
expect(reply._statusCode).toBe(401);
expect(reply._body).toEqual({ error: 'Missing or invalid Authorization header' });
});
it('should return 401 when header does not start with "Bearer "', async () => {
const req = createMockRequest('Basic abc123');
const reply = createMockReply();
await authenticate(req, reply);
expect(reply._statusCode).toBe(401);
expect(reply._body).toEqual({ error: 'Missing or invalid Authorization header' });
});
it('should return 401 with reason when verifyToken returns invalid', async () => {
const revokedReason = 'Token was revoked. Please log in again to get a new token.';
mockVerifyToken.mockResolvedValue({ valid: false, reason: revokedReason });
const req = createMockRequest('Bearer invalid-token');
const reply = createMockReply();
await authenticate(req, reply);
expect(mockVerifyToken).toHaveBeenCalledWith('invalid-token');
expect(reply._statusCode).toBe(401);
expect(reply._body).toEqual({ error: revokedReason });
});
it('should set req.user when token is valid', async () => {
const userPayload = { valid: true, userId: 42, email: 'user@test.com' };
mockVerifyToken.mockResolvedValue(userPayload);
const req = createMockRequest('Bearer valid-token');
const reply = createMockReply();
const result = await authenticate(req, reply);
expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
expect(req.user).toEqual({ userId: 42, email: 'user@test.com' });
// Should return undefined (not sending a reply) when auth succeeds
expect(result).toBeUndefined();
});
it('should extract token correctly when extra spaces exist after Bearer', async () => {
// Note: "Bearer two-spaces" splits on ' ' giving ['Bearer', '', 'two-spaces']
// split(' ')[1] would be '', which verifyToken would reject
mockVerifyToken.mockResolvedValue({ valid: false, reason: 'Invalid token' });
const req = createMockRequest('Bearer extra-space-token');
const reply = createMockReply();
await authenticate(req, reply);
// The implementation uses split(' ')[1], so extra space results in empty string
expect(mockVerifyToken).toHaveBeenCalledWith('');
expect(reply._statusCode).toBe(401);
});
it('should return 401 when Authorization header is empty string', async () => {
const req = createMockRequest('');
const reply = createMockReply();
await authenticate(req, reply);
expect(reply._statusCode).toBe(401);
});
});
describe('getAuthUser', () => {
it('should return user when req.user is set', () => {
const req = createMockRequest();
req.user = { userId: 1, email: 'test@test.com' };
const user = getAuthUser(req);
expect(user).toEqual({ userId: 1, email: 'test@test.com' });
});
it('should throw when req.user is undefined', () => {
const req = createMockRequest();
req.user = undefined;
expect(() => getAuthUser(req)).toThrow('User not authenticated');
});
it('should throw when req.user is not set at all', () => {
const req = { headers: {} } as unknown as FastifyRequest;
expect(() => getAuthUser(req)).toThrow('User not authenticated');
});
});