first auth impl

This commit is contained in:
Wuesteon 2025-12-01 13:30:58 +01:00
parent 8f7c63950c
commit 2a002bf6be
79 changed files with 13355 additions and 6076 deletions

View file

@ -3,7 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}

View file

@ -1,722 +0,0 @@
/**
* Supabase Integration Test Suite
* Tests token sync with Supabase client, RLS policy validation, and storage operations with auth
*/
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { authService } from '../../services/authService';
import { tokenManager, TokenState } from '../../services/tokenManager';
import { setupTokenObservers } from '../../utils/fetchInterceptor';
import {
MOCK_TOKENS,
MOCK_USER_DATA,
MOCK_DEVICE_INFO,
mockFetchResponses,
MockResponseBuilder,
TestScenarioBuilder,
TokenStateObserver,
testUtils,
mockStorage,
} from '../utils/authTestUtils';
// Mock Supabase client
const mockSupabaseClient = {
auth: {
setSession: jest.fn(),
getSession: jest.fn(),
signOut: jest.fn(),
},
from: jest.fn(() => ({
select: jest.fn(() => ({
eq: jest.fn(() => ({
single: jest.fn(),
})),
})),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
})),
storage: {
from: jest.fn(() => ({
upload: jest.fn(),
download: jest.fn(),
remove: jest.fn(),
list: jest.fn(),
})),
},
rpc: jest.fn(),
};
// Mock dependencies
jest.mock('../../utils/safeStorage', () => {
const { mockStorage } = jest.requireActual('../utils/authTestUtils') as any;
return {
safeStorage: mockStorage,
};
});
jest.mock('../../utils/deviceManager', () => {
const { MOCK_DEVICE_INFO } = jest.requireActual('../utils/authTestUtils') as any;
return {
DeviceManager: {
getDeviceInfo: jest.fn().mockResolvedValue(MOCK_DEVICE_INFO),
getStoredDeviceId: jest.fn().mockResolvedValue(MOCK_DEVICE_INFO.deviceId),
},
};
});
jest.mock('../../utils/supabaseClient', () => ({
updateSupabaseAuth: jest.fn(),
supabaseClient: mockSupabaseClient,
}));
jest.mock('../../utils/supabaseDataService', () => ({
initializeSupabaseAuth: jest.fn(),
}));
describe('Supabase Integration', () => {
let tokenObserver: TokenStateObserver;
let consoleMock: ReturnType<typeof testUtils.mockConsole>;
beforeEach(() => {
tokenObserver = new TokenStateObserver();
consoleMock = testUtils.mockConsole();
// Reset token manager state
tokenManager.reset();
// Clear storage
mockStorage.clear();
// Reset all mocks
jest.clearAllMocks();
// Reset fetch mocks
if (globalThis.fetch && typeof (globalThis.fetch as any).mockReset === 'function') {
(globalThis.fetch as jest.Mock).mockReset();
}
});
afterEach(() => {
consoleMock.restore();
});
describe('Token Sync with Supabase Client', () => {
it('should update Supabase auth when token becomes valid', async () => {
// Arrange
const { updateSupabaseAuth } = require('../../utils/supabaseClient');
mockStorage.setupValidTokens();
setupTokenObservers();
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
const token = await tokenManager.getValidToken();
// Wait for state transition
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
// Should update Supabase auth
await testUtils.waitFor(() => updateSupabaseAuth.mock.calls.length > 0);
expect(updateSupabaseAuth).toHaveBeenCalled();
} finally {
unsubscribe();
}
});
it('should handle Supabase auth update after token refresh', async () => {
// Arrange
const { updateSupabaseAuth } = require('../../utils/supabaseClient');
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
setupTokenObservers();
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
const token = await tokenManager.getValidToken();
// Wait for token refresh and state transition
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.REFRESHING));
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
// Should update Supabase auth after refresh
await testUtils.waitFor(() => updateSupabaseAuth.mock.calls.length > 0);
expect(updateSupabaseAuth).toHaveBeenCalled();
} finally {
unsubscribe();
}
});
it('should handle Supabase auth errors gracefully', async () => {
// Arrange
const { updateSupabaseAuth } = require('../../utils/supabaseClient');
updateSupabaseAuth.mockRejectedValue(new Error('Supabase auth error'));
mockStorage.setupValidTokens();
setupTokenObservers();
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
await tokenManager.getValidToken();
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
// Wait for Supabase update attempt
await testUtils.waitFor(() => updateSupabaseAuth.mock.calls.length > 0);
// Assert - Should log error but not crash
expect(updateSupabaseAuth).toHaveBeenCalled();
expect(consoleMock.debugs.some(msg =>
msg.includes('Error updating Supabase auth from token observer')
)).toBe(true);
} finally {
unsubscribe();
}
});
it('should not update Supabase auth on expired token state', async () => {
// Arrange
const { updateSupabaseAuth } = require('../../utils/supabaseClient');
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenExpired().build();
}
return MockResponseBuilder.success().build();
});
setupTokenObservers();
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
await tokenManager.getValidToken();
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.EXPIRED));
// Wait a bit to ensure no Supabase update is called
await testUtils.sleep(200);
// Assert - Should not update Supabase auth for expired tokens
expect(updateSupabaseAuth).not.toHaveBeenCalled();
} finally {
unsubscribe();
}
});
});
describe('RLS Policy Validation with Refreshed Tokens', () => {
it('should validate RLS policies work with refreshed tokens', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
// Mock Supabase query that requires RLS
const mockQuery = mockSupabaseClient.from('test_table').select('*').eq('user_id', MOCK_USER_DATA.sub);
mockQuery.single.mockResolvedValue({
data: { id: 1, name: 'test', user_id: MOCK_USER_DATA.sub },
error: null,
});
// Act - Get valid token (will trigger refresh)
const token = await tokenManager.getValidToken();
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
// Simulate RLS-protected query
const result = await mockQuery.single();
// Assert
expect(result.data).toBeDefined();
expect(result.error).toBeNull();
expect(result.data.user_id).toBe(MOCK_USER_DATA.sub);
});
it('should handle RLS policy failures with expired tokens', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenExpired().build();
}
return MockResponseBuilder.success().build();
});
// Mock Supabase query that fails due to RLS
const mockQuery = mockSupabaseClient.from('test_table').select('*').eq('user_id', MOCK_USER_DATA.sub);
mockQuery.single.mockResolvedValue({
data: null,
error: {
message: 'JWT expired',
code: 'PGRST301',
},
});
// Act
const token = await tokenManager.getValidToken();
expect(token).toBeNull();
// Simulate RLS-protected query with expired token
const result = await mockQuery.single();
// Assert
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error.code).toBe('PGRST301');
});
it('should retry queries after token refresh on RLS failures', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
let queryAttempts = 0;
const mockQuery = mockSupabaseClient.from('test_table').select('*').eq('user_id', MOCK_USER_DATA.sub);
mockQuery.single.mockImplementation(async () => {
queryAttempts++;
// First attempt fails with JWT expired
if (queryAttempts === 1) {
return {
data: null,
error: {
message: 'JWT expired',
code: 'PGRST301',
},
};
}
// Second attempt succeeds after token refresh
return {
data: { id: 1, name: 'test', user_id: MOCK_USER_DATA.sub },
error: null,
};
});
// Act - First get valid token
await tokenManager.getValidToken();
// First query fails
let result = await mockQuery.single();
expect(result.error?.code).toBe('PGRST301');
// Trigger token refresh and retry
await tokenManager.getValidToken();
result = await mockQuery.single();
// Assert - Second attempt should succeed
expect(result.data).toBeDefined();
expect(result.error).toBeNull();
expect(queryAttempts).toBe(2);
});
});
describe('Storage Operations with Auth', () => {
it('should perform storage operations with valid tokens', async () => {
// Arrange
mockStorage.setupValidTokens();
const mockStorageBucket = mockSupabaseClient.storage.from('test-bucket');
mockStorageBucket.upload.mockResolvedValue({
data: { path: 'test-file.jpg' },
error: null,
});
// Act
const token = await tokenManager.getValidToken();
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
const uploadResult = await mockStorageBucket.upload('test-file.jpg', new Blob(['test data']));
// Assert
expect(uploadResult.data).toBeDefined();
expect(uploadResult.error).toBeNull();
expect(uploadResult.data.path).toBe('test-file.jpg');
});
it('should handle storage operations with expired tokens', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenExpired().build();
}
return MockResponseBuilder.success().build();
});
const mockStorageBucket = mockSupabaseClient.storage.from('test-bucket');
mockStorageBucket.upload.mockResolvedValue({
data: null,
error: {
message: 'JWT expired',
statusCode: '401',
},
});
// Act
const token = await tokenManager.getValidToken();
expect(token).toBeNull();
const uploadResult = await mockStorageBucket.upload('test-file.jpg', new Blob(['test data']));
// Assert
expect(uploadResult.data).toBeNull();
expect(uploadResult.error).toBeDefined();
expect(uploadResult.error.statusCode).toBe('401');
});
it('should refresh tokens automatically during storage operations', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
let uploadAttempts = 0;
const mockStorageBucket = mockSupabaseClient.storage.from('test-bucket');
mockStorageBucket.upload.mockImplementation(async () => {
uploadAttempts++;
// First attempt fails with expired token
if (uploadAttempts === 1) {
return {
data: null,
error: {
message: 'JWT expired',
statusCode: '401',
},
};
}
// Second attempt succeeds after token refresh
return {
data: { path: 'test-file.jpg' },
error: null,
};
});
// Act - Get valid token (triggers refresh)
await tokenManager.getValidToken();
// First storage attempt fails
let result = await mockStorageBucket.upload('test-file.jpg', new Blob(['test data']));
expect(result.error?.statusCode).toBe('401');
// Get valid token again and retry
await tokenManager.getValidToken();
result = await mockStorageBucket.upload('test-file.jpg', new Blob(['test data']));
// Assert
expect(result.data).toBeDefined();
expect(result.error).toBeNull();
expect(uploadAttempts).toBe(2);
});
it('should handle storage download operations with auth', async () => {
// Arrange
mockStorage.setupValidTokens();
const mockStorageBucket = mockSupabaseClient.storage.from('test-bucket');
mockStorageBucket.download.mockResolvedValue({
data: new Blob(['test file content']),
error: null,
});
// Act
const token = await tokenManager.getValidToken();
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
const downloadResult = await mockStorageBucket.download('test-file.jpg');
// Assert
expect(downloadResult.data).toBeDefined();
expect(downloadResult.error).toBeNull();
expect(downloadResult.data).toBeInstanceOf(Blob);
});
it('should handle storage listing operations with auth', async () => {
// Arrange
mockStorage.setupValidTokens();
const mockStorageBucket = mockSupabaseClient.storage.from('test-bucket');
mockStorageBucket.list.mockResolvedValue({
data: [
{ name: 'file1.jpg', id: 'id1' },
{ name: 'file2.jpg', id: 'id2' },
],
error: null,
});
// Act
const token = await tokenManager.getValidToken();
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
const listResult = await mockStorageBucket.list('folder');
// Assert
expect(listResult.data).toBeDefined();
expect(listResult.error).toBeNull();
expect(listResult.data).toHaveLength(2);
expect(listResult.data[0].name).toBe('file1.jpg');
});
});
describe('Supabase Auth Session Management', () => {
it('should set Supabase session with valid tokens', async () => {
// Arrange
mockStorage.setupValidTokens();
const { updateSupabaseAuth } = require('../../utils/supabaseClient');
updateSupabaseAuth.mockImplementation(async () => {
await mockSupabaseClient.auth.setSession({
access_token: MOCK_TOKENS.VALID_APP_TOKEN,
refresh_token: MOCK_TOKENS.VALID_REFRESH_TOKEN,
});
});
setupTokenObservers();
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
await tokenManager.getValidToken();
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
await testUtils.waitFor(() => updateSupabaseAuth.mock.calls.length > 0);
// Assert
expect(mockSupabaseClient.auth.setSession).toHaveBeenCalledWith({
access_token: MOCK_TOKENS.VALID_APP_TOKEN,
refresh_token: MOCK_TOKENS.VALID_REFRESH_TOKEN,
});
} finally {
unsubscribe();
}
});
it('should get Supabase session after auth update', async () => {
// Arrange
mockSupabaseClient.auth.getSession.mockResolvedValue({
data: {
session: {
access_token: MOCK_TOKENS.VALID_APP_TOKEN,
refresh_token: MOCK_TOKENS.VALID_REFRESH_TOKEN,
user: {
id: MOCK_USER_DATA.sub,
email: MOCK_USER_DATA.email,
},
},
},
error: null,
});
// Act
const sessionResult = await mockSupabaseClient.auth.getSession();
// Assert
expect(sessionResult.data.session).toBeDefined();
expect(sessionResult.error).toBeNull();
expect(sessionResult.data.session.access_token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
expect(sessionResult.data.session.user.id).toBe(MOCK_USER_DATA.sub);
});
it('should handle Supabase sign out', async () => {
// Arrange
mockSupabaseClient.auth.signOut.mockResolvedValue({
error: null,
});
// Act
const signOutResult = await mockSupabaseClient.auth.signOut();
// Assert
expect(signOutResult.error).toBeNull();
expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled();
});
it('should handle Supabase session errors', async () => {
// Arrange
mockSupabaseClient.auth.getSession.mockResolvedValue({
data: { session: null },
error: {
message: 'No active session',
status: 401,
},
});
// Act
const sessionResult = await mockSupabaseClient.auth.getSession();
// Assert
expect(sessionResult.data.session).toBeNull();
expect(sessionResult.error).toBeDefined();
expect(sessionResult.error.message).toBe('No active session');
});
});
describe('Integration Error Scenarios', () => {
it('should handle Supabase client initialization failures', async () => {
// Arrange
const { initializeSupabaseAuth } = require('../../utils/supabaseDataService');
initializeSupabaseAuth.mockRejectedValue(new Error('Supabase initialization failed'));
mockStorage.setupValidTokens();
// Act & Assert - Should not crash the auth flow
const token = await tokenManager.getValidToken();
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
// Supabase initialization failure should be handled gracefully
expect(consoleMock.logs.some(msg =>
msg.includes('Supabase initialization skipped')
)).toBe(false); // This happens at a higher level
});
it('should handle mixed auth success and Supabase failures', async () => {
// Arrange
const { updateSupabaseAuth } = require('../../utils/supabaseClient');
updateSupabaseAuth.mockRejectedValue(new Error('Supabase update failed'));
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
setupTokenObservers();
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act - Token refresh should succeed even if Supabase update fails
const token = await tokenManager.getValidToken();
// Wait for states
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
// Supabase update should have been attempted and failed gracefully
await testUtils.waitFor(() => updateSupabaseAuth.mock.calls.length > 0);
expect(updateSupabaseAuth).toHaveBeenCalled();
expect(consoleMock.debugs.some(msg =>
msg.includes('Error updating Supabase auth from token observer')
)).toBe(true);
} finally {
unsubscribe();
}
});
it('should handle partial Supabase operations during token transitions', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let refreshComplete = false;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
await testUtils.sleep(200);
refreshComplete = true;
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
const mockQuery = mockSupabaseClient.from('test_table').select('*');
mockQuery.eq.mockImplementation(() => {
if (!refreshComplete) {
return {
single: jest.fn().mockResolvedValue({
data: null,
error: { message: 'JWT expired', code: 'PGRST301' },
}),
};
}
return {
single: jest.fn().mockResolvedValue({
data: { id: 1, name: 'test' },
error: null,
}),
};
});
// Act - Start refresh and immediately try to use Supabase
const tokenPromise = tokenManager.getValidToken();
// Try to query before refresh completes
const earlyResult = await mockQuery.eq('id', 1).single();
// Wait for refresh to complete
await tokenPromise;
// Try to query after refresh completes
const lateResult = await mockQuery.eq('id', 1).single();
// Assert
expect(earlyResult.error?.code).toBe('PGRST301');
expect(lateResult.data).toBeDefined();
expect(lateResult.error).toBeNull();
});
});
});

View file

@ -1,641 +0,0 @@
/**
* Token Refresh Flow Test Suite
* Tests all aspects of the token refresh system including race conditions and concurrent requests
*/
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { authService } from '../../services/authService';
import { tokenManager, TokenState } from '../../services/tokenManager';
import {
MOCK_TOKENS,
MOCK_USER_DATA,
MOCK_DEVICE_INFO,
mockFetchResponses,
MockResponseBuilder,
TestScenarioBuilder,
TokenStateObserver,
NetworkCondition,
testUtils,
mockStorage,
} from '../utils/authTestUtils';
// Mock dependencies
jest.mock('../../utils/safeStorage', () => {
const { mockStorage } = jest.requireActual('../utils/authTestUtils') as any;
return {
safeStorage: mockStorage,
};
});
jest.mock('../../utils/deviceManager', () => {
const { MOCK_DEVICE_INFO } = jest.requireActual('../utils/authTestUtils') as any;
return {
DeviceManager: {
getDeviceInfo: jest.fn().mockResolvedValue(MOCK_DEVICE_INFO),
getStoredDeviceId: jest.fn().mockResolvedValue(MOCK_DEVICE_INFO.deviceId),
},
};
});
jest.mock('../../utils/networkErrorUtils', () => ({
hasStableConnection: jest.fn().mockResolvedValue(true),
isDeviceConnected: jest.fn().mockResolvedValue(true),
}));
describe('Token Refresh Flow', () => {
let tokenObserver: TokenStateObserver;
let consoleMock: ReturnType<typeof testUtils.mockConsole>;
beforeEach(() => {
tokenObserver = new TokenStateObserver();
consoleMock = testUtils.mockConsole();
// Reset token manager state
tokenManager.reset();
// Clear storage
mockStorage.clear();
// Reset fetch mocks
if (globalThis.fetch && typeof (globalThis.fetch as any).mockReset === 'function') {
(globalThis.fetch as jest.Mock).mockReset();
}
});
afterEach(() => {
consoleMock.restore();
});
describe('Automatic Token Refresh', () => {
it.skip('should refresh token automatically on 401 response', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let callCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
// First call returns 401, second call succeeds
callCount++;
if (callCount === 1) {
return MockResponseBuilder.unauthorized('JWT expired').build();
}
return MockResponseBuilder.success({ data: 'success' }).build();
});
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act - Make a request that will trigger token refresh
const response = await tokenManager.handle401Response('http://localhost:3002/api/test', {
method: 'GET',
headers: { 'Authorization': `Bearer ${MOCK_TOKENS.EXPIRED_APP_TOKEN}` },
});
// Assert
expect(response.status).toBe(200);
expect(callCount).toBe(2); // One 401, one retry with new token
// Wait for token state update
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.REFRESHING));
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
// Verify new token was stored
const newToken = await mockStorage.getItem('@auth/appToken');
expect(newToken).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
} finally {
unsubscribe();
}
});
it('should queue concurrent requests during token refresh', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let refreshCallCount = 0;
let apiCallCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
refreshCallCount++;
// Simulate slow refresh
await testUtils.sleep(500);
return mockFetchResponses.refreshTokenSuccess().build();
}
if (url.includes('/api/test')) {
apiCallCount++;
// Return success after refresh
return MockResponseBuilder.success({ data: `response-${apiCallCount}` }).build();
}
return MockResponseBuilder.unauthorized('JWT expired').build();
});
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act - Make multiple concurrent requests
const requests = [
tokenManager.handle401Response('http://localhost:3002/api/test1', { method: 'GET' }),
tokenManager.handle401Response('http://localhost:3002/api/test2', { method: 'GET' }),
tokenManager.handle401Response('http://localhost:3002/api/test3', { method: 'GET' }),
];
const responses = await Promise.all(requests);
// Assert
expect(refreshCallCount).toBe(1); // Only one refresh should occur
expect(responses).toHaveLength(3);
responses.forEach(response => {
expect(response.status).toBe(200);
});
// Verify token manager handled queuing correctly
const queueStatus = tokenManager.getQueueStatus();
expect(queueStatus.size).toBe(0); // Queue should be empty after processing
} finally {
unsubscribe();
}
});
it('should handle refresh token expiration', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenExpired().build();
}
return MockResponseBuilder.unauthorized('JWT expired').build();
});
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act & Assert
await expect(tokenManager.handle401Response('http://localhost:3002/api/test', { method: 'GET' }))
.rejects.toThrow('Invalid refresh token');
// Wait for state updates
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.EXPIRED));
// Verify tokens were cleared
const appToken = await mockStorage.getItem('@auth/appToken');
const refreshToken = await mockStorage.getItem('@auth/refreshToken');
expect(appToken).toBeNull();
expect(refreshToken).toBeNull();
} finally {
unsubscribe();
}
});
it('should detect device ID changes and handle appropriately', async () => {
// Arrange
mockStorage.setupExpiredTokens();
// Mock device ID mismatch
const { DeviceManager } = require('../../utils/deviceManager');
DeviceManager.getStoredDeviceId.mockResolvedValueOnce('old-device-id');
DeviceManager.getDeviceInfo.mockResolvedValueOnce({
...MOCK_DEVICE_INFO,
deviceId: 'new-device-id',
});
globalThis.fetch = jest.fn().mockImplementation(async () => {
return mockFetchResponses.refreshTokenDeviceChanged().build();
});
// Act & Assert
await expect(authService.refreshTokens(MOCK_TOKENS.VALID_REFRESH_TOKEN))
.rejects.toThrow('Device ID has changed');
});
});
describe('Token Refresh Race Conditions', () => {
it('should prevent multiple simultaneous refresh attempts', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let refreshCallCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
refreshCallCount++;
// Simulate slow refresh
await testUtils.sleep(300);
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
// Act - Start multiple refresh attempts simultaneously
const refreshPromises = [
tokenManager.getValidToken(),
tokenManager.getValidToken(),
tokenManager.getValidToken(),
];
const tokens = await Promise.all(refreshPromises);
// Assert
expect(refreshCallCount).toBe(1); // Only one refresh should occur
tokens.forEach(token => {
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
});
});
it('should handle refresh cooldown period', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
// Act - Make first refresh
const firstToken = await tokenManager.getValidToken();
expect(firstToken).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
// Try to refresh again immediately (should be in cooldown)
mockStorage.setItem('@auth/appToken', MOCK_TOKENS.EXPIRED_APP_TOKEN);
const secondToken = await tokenManager.getValidToken();
// Assert - Should get expired token due to cooldown
expect(secondToken).toBe(null);
});
it('should handle max refresh attempts', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let refreshCallCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
refreshCallCount++;
// Fail first few attempts, succeed on last
if (refreshCallCount <= 2) {
throw new Error('Network error');
}
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.unauthorized('JWT expired').build();
});
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
const token = await tokenManager.getValidToken();
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
expect(refreshCallCount).toBeGreaterThan(1); // Multiple attempts made
} finally {
unsubscribe();
}
});
});
describe('Network Error Handling During Refresh', () => {
it('should retry refresh on network errors', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let attemptCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
attemptCount++;
if (attemptCount <= 2) {
throw new Error('Network request failed');
}
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
// Act
const token = await tokenManager.getValidToken();
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
expect(attemptCount).toBe(3); // Should retry network failures
});
it('should not retry on auth errors', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let attemptCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
attemptCount++;
return mockFetchResponses.refreshTokenExpired().build();
}
return MockResponseBuilder.success().build();
});
// Act & Assert
const token = await tokenManager.getValidToken();
expect(token).toBe(null);
expect(attemptCount).toBe(1); // Should not retry auth errors
});
it('should handle offline state during refresh', async () => {
// Arrange
mockStorage.setupExpiredTokens();
const { isDeviceConnected } = require('../../utils/networkErrorUtils');
isDeviceConnected.mockResolvedValueOnce(false);
// Act
const token = await tokenManager.getValidToken();
// Assert - Should return current token if offline and it's not expired locally
expect(token).toBe(null);
});
it('should handle unstable connection during refresh', async () => {
// Arrange
mockStorage.setupExpiredTokens();
const { isDeviceConnected, hasStableConnection } = require('../../utils/networkErrorUtils');
isDeviceConnected.mockResolvedValue(true);
hasStableConnection.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
let attemptCount = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
attemptCount++;
if (attemptCount === 1) {
// First attempt should not be made due to unstable connection
return mockFetchResponses.refreshTokenSuccess().build();
}
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
// Act
const token = await tokenManager.getValidToken();
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
expect(attemptCount).toBeGreaterThan(0);
});
});
describe('Token Refresh State Management', () => {
it('should properly transition through token states', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
// Simulate slow refresh
await testUtils.sleep(200);
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
const tokenPromise = tokenManager.getValidToken();
// Assert - Should transition through states
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.REFRESHING));
const token = await tokenPromise;
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
await testUtils.waitFor(() => tokenObserver.hasState(TokenState.VALID));
const stateTransitions = tokenObserver.getStateTransitions();
expect(stateTransitions).toContain(TokenState.REFRESHING);
expect(stateTransitions).toContain(TokenState.VALID);
} finally {
unsubscribe();
}
});
it('should notify observers of token state changes', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
const unsubscribe = tokenManager.subscribe(tokenObserver.getCallback());
let observerCallCount = 0;
const testObserver = tokenManager.subscribe(() => {
observerCallCount++;
});
try {
// Act
await tokenManager.getValidToken();
// Assert
expect(observerCallCount).toBeGreaterThan(0);
expect(tokenObserver.getStates().length).toBeGreaterThan(0);
} finally {
unsubscribe();
testObserver();
}
});
it('should handle observer errors gracefully', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.success().build();
});
// Observer that throws error
const errorObserver = jest.fn().mockImplementation(() => {
throw new Error('Observer error');
});
const unsubscribe1 = tokenManager.subscribe(errorObserver);
const unsubscribe2 = tokenManager.subscribe(tokenObserver.getCallback());
try {
// Act
const token = await tokenManager.getValidToken();
// Assert
expect(token).toBe(MOCK_TOKENS.VALID_APP_TOKEN);
expect(errorObserver).toHaveBeenCalled();
expect(tokenObserver.getStates().length).toBeGreaterThan(0); // Other observers still work
} finally {
unsubscribe1();
unsubscribe2();
}
});
});
describe('Request Queueing', () => {
it('should queue requests during token refresh and process them after', async () => {
// Arrange
mockStorage.setupExpiredTokens();
let refreshStarted = false;
let requestsProcessed = 0;
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
refreshStarted = true;
await testUtils.sleep(500); // Slow refresh
return mockFetchResponses.refreshTokenSuccess().build();
}
if (url.includes('/api/test')) {
requestsProcessed++;
return MockResponseBuilder.success({ data: `response-${requestsProcessed}` }).build();
}
return MockResponseBuilder.unauthorized('JWT expired').build();
});
// Act - Start refresh and queue requests
const refreshPromise = tokenManager.handle401Response('http://localhost:3002/api/initial', { method: 'GET' });
// Wait for refresh to start
await testUtils.waitFor(() => refreshStarted);
// Queue additional requests
const queuedRequests = [
tokenManager.handle401Response('http://localhost:3002/api/test1', { method: 'GET' }),
tokenManager.handle401Response('http://localhost:3002/api/test2', { method: 'GET' }),
];
// Wait for all requests to complete
const [initialResponse, ...queuedResponses] = await Promise.all([refreshPromise, ...queuedRequests]);
// Assert
expect(initialResponse.status).toBe(200);
queuedResponses.forEach(response => {
expect(response.status).toBe(200);
});
expect(requestsProcessed).toBe(3); // All requests were processed after refresh
});
it('should handle queue timeout', async () => {
// Arrange
mockStorage.setupExpiredTokens();
// Mock a very slow refresh that exceeds queue timeout
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
await testUtils.sleep(35000); // Longer than queue timeout (30s)
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.unauthorized('JWT expired').build();
});
// Start refresh
tokenManager.handle401Response('http://localhost:3002/api/initial', { method: 'GET' });
await testUtils.sleep(100); // Let refresh start
// Act & Assert - Queue a request that should timeout
await expect(
tokenManager.handle401Response('http://localhost:3002/api/test', { method: 'GET' })
).rejects.toThrow('Queued request timeout');
});
it('should handle queue size limit', async () => {
// Arrange
mockStorage.setupExpiredTokens();
globalThis.fetch = jest.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/auth/refresh')) {
await testUtils.sleep(1000); // Slow refresh
return mockFetchResponses.refreshTokenSuccess().build();
}
return MockResponseBuilder.unauthorized('JWT expired').build();
});
// Start refresh
tokenManager.handle401Response('http://localhost:3002/api/initial', { method: 'GET' });
await testUtils.sleep(100); // Let refresh start
// Act - Queue many requests (more than MAX_QUEUE_SIZE = 50)
const queuePromises = [];
for (let i = 0; i < 52; i++) {
queuePromises.push(
tokenManager.handle401Response(`http://localhost:3002/api/test${i}`, { method: 'GET' })
.catch(error => error)
);
}
const results = await Promise.all(queuePromises);
// Assert - Some requests should be rejected due to queue limit
const errors = results.filter(result => result instanceof Error);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(error => error.message === 'Request queue full')).toBe(true);
});
});
});

View file

@ -1,31 +0,0 @@
version: '3.8'
services:
nutriphi-backend:
build:
context: .
dockerfile: Dockerfile
container_name: nutriphi-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=${PORT:-3002}
- DATABASE_URL=${DATABASE_URL}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME}
- S3_REGION=${S3_REGION:-fsn1}
- S3_PUBLIC_URL=${S3_PUBLIC_URL}
- MANACORE_AUTH_URL=${MANACORE_AUTH_URL}
ports:
- "${PORT:-3002}:${PORT:-3002}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "coolify.managed=true"

View file

@ -949,13 +949,4 @@ declare function isValidThemeVariant(variant: string): variant is ThemeVariant;
*/
type NativeTheme = ReturnType<typeof createNativeTheme>;
export {
type ColorMode,
type NativeTheme,
type SemanticColors,
type ThemeVariant,
createNativeTheme,
getThemeColors,
getThemeVariants,
isValidThemeVariant,
};
export { type ColorMode, type NativeTheme, type SemanticColors, type ThemeVariant, createNativeTheme, getThemeColors, getThemeVariants, isValidThemeVariant };

File diff suppressed because it is too large Load diff

View file

@ -56,7 +56,7 @@ docker-compose up --build
## 📝 Documentation
- [Deployment Guide](./DEPLOYMENT.md) - Complete Coolify deployment instructions
- [Deployment Guide](./DEPLOYMENT.md) - Complete Docker Compose deployment instructions
- [Lessons Learned](./DEPLOYMENT_LESSONS_LEARNED.md) - Troubleshooting and insights
- [Domain Setup](./DOMAIN_SETUP_ULO_AD.md) - ulo.ad configuration
- [Coolify Setup](./COOLIFY_SETUP.md) - Detailed Coolify configuration

View file

@ -1,50 +0,0 @@
# =============================================================================
# uload Docker Compose - Coolify Deployment
# =============================================================================
# This file is used by Coolify for deployment.
# Environment variables are injected by Coolify.
# =============================================================================
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
environment:
NODE_ENV: production
PORT: 3000
HOST: 0.0.0.0
ORIGIN: ${ORIGIN:-https://ulo.ad}
# Database (set in Coolify)
DATABASE_URL: ${DATABASE_URL}
# Redis (optional, set in Coolify)
REDIS_URL: ${REDIS_URL:-}
# Auth
AUTH_SECRET: ${AUTH_SECRET}
# External Services (set in Coolify)
RESEND_API_KEY: ${RESEND_API_KEY:-}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
# R2 Storage (set in Coolify)
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-}
R2_ENDPOINT: ${R2_ENDPOINT:-}
# Analytics (optional)
PUBLIC_UMAMI_URL: ${PUBLIC_UMAMI_URL:-}
PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View file

@ -2,7 +2,7 @@
# uload Docker Compose - Production (standalone)
# =============================================================================
# Use this for manual production deployment without Coolify.
# For Coolify deployments, use docker-compose.coolify.yml instead.
# For Docker Compose deployments, use docker-compose.coolify.yml instead.
# =============================================================================
services:

View file

@ -17,7 +17,7 @@ Diese Anleitung beschreibt das Deployment einer SvelteKit + PocketBase Anwendung
│ Hetzner VPS │
│ │
│ ┌─────────────────────────────┐ │
│ │ Coolify Platform │ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ Docker Container │ │ │

View file

@ -11,7 +11,7 @@ Deployment einer SvelteKit + PocketBase Anwendung auf Hetzner VPS mit Coolify.
│ Hetzner VPS (91.99.221.179) │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Coolify Platform │ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ Docker Container │ │ │
@ -116,7 +116,7 @@ POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
- **Problem:** Supervisor kann nicht starten ohne die ENV Variables
- **Symptom:** Endlosschleife im Container mit Supervisor Error
- **Lösung:** ALLE benötigten ENV Variables in Coolify UI setzen
- **Lösung:** ALLE benötigten ENV Variables in Docker Compose configuration setzen
### 2. Docker Build Context