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
44 changes: 35 additions & 9 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,42 @@ name: E2E Tests

on:
push:
branches: [main, develop]
branches: [main]
pull_request:
branches: [main, develop]
branches: [main]
workflow_dispatch:

env:
CLOUD_VERSION: latest-amd64

jobs:
run_e2e_tests:
# Run different test categories in parallel for 6x speedup
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
# Group tests by directory/feature for parallel execution
test-group:
- name: "auth"
spec: "cypress/e2e/auth/**/*.cy.ts"
description: "Authentication (OAuth, OTP, Password, Login/Logout)"
- name: "editor"
spec: "cypress/e2e/editor/**/*.cy.ts"
description: "Document editing and formatting"
- name: "database"
spec: "cypress/e2e/database/**/*.cy.ts"
description: "Database and grid operations"
- name: "page"
spec: "cypress/e2e/page/**/*.cy.ts"
description: "Page management (create, delete, share, publish)"
- name: "chat"
spec: "cypress/e2e/chat/**/*.cy.ts"
description: "AI chat features"
- name: "account-space-user"
spec: "cypress/e2e/{account,space,user,app}/**/*.cy.ts"
description: "Account, Space, User, and App tests"
name: "${{ matrix.test-group.name }}"
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down Expand Up @@ -113,8 +138,9 @@ jobs:
exit 1
)

# Run HTTP API integration tests
# Run HTTP API integration tests (only once, in auth group)
- name: Run HTTP API integration tests
if: matrix.test-group.name == 'auth'
run: |
echo "Running HTTP API integration tests..."
pnpm run test:http-api
Expand Down Expand Up @@ -144,17 +170,17 @@ jobs:
sleep 2
done' && echo "✓ Preview server is ready" || (echo "❌ Preview server failed to start" && exit 1)

- name: Run tests
- name: Run ${{ matrix.test-group.name }} tests
run: |
pnpm cypress run \
--spec 'cypress/e2e/**/*.cy.ts' \
--spec '${{ matrix.test-group.spec }}' \
--config video=false

- name: Upload artifacts
if: always()
- name: Upload artifacts (${{ matrix.test-group.name }})
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results
name: test-results-${{ matrix.test-group.name }}
path: |
cypress/screenshots/**
cypress/logs/**
Expand Down
187 changes: 187 additions & 0 deletions cypress/e2e/auth/oauth-login.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,5 +485,192 @@ describe('OAuth Login Flow', () => {
cy.log('[STEP 5] Redirect loop prevention test passed - token persisted and no redirect occurred');
});
});

describe('Old Token Race Condition', () => {
it('should clear old expired token before processing OAuth callback', () => {
const oldExpiredToken = 'old-expired-token-' + uuidv4();
const oldRefreshToken = 'old-expired-refresh-' + uuidv4();
const newAccessToken = 'new-oauth-token-' + uuidv4();
const newRefreshToken = 'new-oauth-refresh-' + uuidv4();
const mockUserId = uuidv4();
const mockWorkspaceId = uuidv4();

cy.log('[TEST START] Testing old expired token race condition');

cy.log('[SETUP] Pre-populate localStorage with expired token');
cy.window().then((win) => {
// Set old expired token (expired 1 hour ago)
win.localStorage.setItem('token', JSON.stringify({
access_token: oldExpiredToken,
refresh_token: oldRefreshToken,
expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired!
user: {
id: mockUserId,
email: '[email protected]',
},
}));
});

// Mock refresh endpoint to FAIL for old token, SUCCESS for new token
cy.intercept('POST', `${gotrueUrl}/token?grant_type=refresh_token`, (req) => {
const { body } = req;

if (body.refresh_token === oldRefreshToken) {
// Old token refresh should fail
req.reply({
statusCode: 400,
body: {
error: 'invalid_grant',
error_description: 'Refresh token is invalid or expired',
},
});
} else if (body.refresh_token === newRefreshToken) {
// New token refresh should succeed
req.reply({
statusCode: 200,
body: {
access_token: newAccessToken,
refresh_token: newRefreshToken,
expires_at: Math.floor(Date.now() / 1000) + 3600,
user: {
id: mockUserId,
email: '[email protected]',
email_confirmed_at: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
} else {
// Unknown token
req.reply({ statusCode: 400, body: { error: 'unknown_token' } });
}
}).as('refreshToken');

// Mock verify for NEW token only
cy.intercept('GET', `${apiUrl}/api/user/verify/${newAccessToken}`, {
statusCode: 200,
body: {
code: 0,
data: { is_new: false },
message: 'Success',
},
}).as('verifyNewToken');

// Mock verify for OLD token (should NOT be called if fix is working)
cy.intercept('GET', `${apiUrl}/api/user/verify/${oldExpiredToken}`, {
statusCode: 401,
body: {
code: 401,
message: 'Token expired',
},
}).as('verifyOldToken');

// Mock workspace endpoints
cy.intercept('GET', `${apiUrl}/api/user/workspace`, {
statusCode: 200,
body: {
code: 0,
data: {
user_profile: { uuid: mockUserId },
visiting_workspace: {
workspace_id: mockWorkspaceId,
workspace_name: 'My Workspace',
icon: '',
created_at: Date.now().toString(),
database_storage_id: '',
owner_uid: 1,
owner_name: 'Test User',
member_count: 1,
},
workspaces: [
{
workspace_id: mockWorkspaceId,
workspace_name: 'My Workspace',
icon: '',
created_at: Date.now().toString(),
database_storage_id: '',
owner_uid: 1,
owner_name: 'Test User',
member_count: 1,
},
],
},
message: 'Success',
},
}).as('getUserWorkspaceInfo');

cy.intercept('GET', `${apiUrl}/api/user/profile*`, {
statusCode: 200,
body: {
code: 0,
data: {
uid: 1,
uuid: mockUserId,
email: '[email protected]',
name: 'Test User',
metadata: {},
encryption_sign: null,
latest_workspace_id: mockWorkspaceId,
updated_at: Date.now(),
},
message: 'Success',
},
}).as('getCurrentUser');

// Step 1: Simulate OAuth callback with NEW tokens
cy.log('[STEP 1] Simulating OAuth callback with NEW tokens (old expired token in localStorage)');
const callbackUrl = `${baseUrl}/auth/callback#access_token=${newAccessToken}&refresh_token=${newRefreshToken}&expires_at=${Math.floor(Date.now() / 1000) + 3600
}&token_type=bearer`;

cy.visit(callbackUrl, { failOnStatusCode: false });
cy.wait(2000);

// Step 2: Verify NEW token was used for verification (not old token)
cy.log('[STEP 2] Verifying NEW token is used for verification');
cy.wait('@verifyNewToken').then((interception) => {
expect(interception.response?.statusCode).to.equal(200);
cy.log('[SUCCESS] verifyToken called with NEW token (old token was cleared first)');
});

// Step 3: Verify refresh was called with NEW token (not old expired token)
cy.log('[STEP 3] Verifying refresh called with NEW token');
cy.wait('@refreshToken').then((interception) => {
const requestBody = interception.request.body;

expect(requestBody.refresh_token).to.equal(newRefreshToken);
cy.log('[SUCCESS] refreshToken called with NEW token (not old expired token)');
});

// Step 4: Verify we're redirected to /app (not /login due to token invalidation)
cy.log('[STEP 4] Verifying successful redirect to /app');
cy.url({ timeout: 15000 }).should('include', '/app');
cy.url().should('not.include', '/login');

// Step 5: Verify NEW token is saved (old token replaced)
cy.log('[STEP 5] Verifying NEW token is saved in localStorage');
cy.window().then((win) => {
const token = win.localStorage.getItem('token');

expect(token).to.exist;
const tokenData = JSON.parse(token || '{}');

expect(tokenData.access_token).to.equal(newAccessToken);
expect(tokenData.refresh_token).to.equal(newRefreshToken);
// Old token should be completely replaced
expect(tokenData.access_token).to.not.equal(oldExpiredToken);
expect(tokenData.refresh_token).to.not.equal(oldRefreshToken);
cy.log('[SUCCESS] NEW token saved, old token replaced');
});

// Step 6: Wait to ensure no redirect loop occurs
cy.log('[STEP 6] Verifying no redirect loop (session not invalidated)');
cy.wait(3000);
cy.url().should('include', '/app');
cy.url().should('not.include', '/login');

cy.log('[TEST COMPLETE] Old token race condition handled correctly - old token cleared before OAuth processing');
});
});
});

38 changes: 38 additions & 0 deletions src/application/services/js-services/http/http_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ export function initAPIService(config: AFCloudConfig) {
const token = getTokenParsed();

if (!token) {
console.debug('[initAPIService][request] no token found, sending request without auth header', {
url: config.url,
});
return config;
}

Expand All @@ -237,6 +240,10 @@ export function initAPIService(config: AFCloudConfig) {

access_token = newToken?.access_token || '';
} catch (e) {
console.warn('[initAPIService][request] refresh token failed, marking token invalid', {
url: config.url,
message: (e as Error)?.message,
});
invalidToken();
return config;
}
Expand All @@ -262,6 +269,7 @@ export function initAPIService(config: AFCloudConfig) {
const token = getTokenParsed();

if (!token) {
console.warn('[initAPIService][response] 401 without token, emitting invalid token');
invalidToken();
return response;
}
Expand All @@ -271,6 +279,10 @@ export function initAPIService(config: AFCloudConfig) {
try {
await refreshToken(refresh_token);
} catch (e) {
console.warn('[initAPIService][response] refresh on 401 failed, emitting invalid token', {
message: (e as Error)?.message,
url: response.config?.url,
});
invalidToken();
}
}
Expand All @@ -286,6 +298,10 @@ export async function signInWithUrl(url: string) {
const gotrueError = parseGoTrueErrorFromUrl(url);

if (gotrueError) {
console.warn('[signInWithUrl] GoTrue error detected in callback URL', {
code: gotrueError.code,
message: gotrueError.message,
});
// GoTrue returned an error, reject with parsed error
return Promise.reject({
code: gotrueError.code,
Expand All @@ -298,6 +314,7 @@ export async function signInWithUrl(url: string) {
const hash = urlObj.hash;

if (!hash) {
console.warn('[signInWithUrl] No hash found in callback URL');
return Promise.reject('No hash found');
}

Expand All @@ -306,15 +323,35 @@ export async function signInWithUrl(url: string) {
const refresh_token = params.get('refresh_token');

if (!accessToken || !refresh_token) {
console.warn('[signInWithUrl] Missing tokens in callback hash', {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refresh_token,
});
return Promise.reject({
code: -1,
message: 'No access token or refresh token found',
});
}

// CRITICAL: Clear old token BEFORE processing new OAuth tokens
// This prevents axios interceptor from trying to auto-refresh the old expired token
// during verifyToken() API call, which would cause a race condition where:
// 1. verifyToken() makes API call with NEW token in URL
// 2. Axios interceptor sees OLD token in localStorage, tries to refresh it
// 3. Old token refresh fails → invalidToken() called → session invalidated
// 4. Meanwhile, OAuth flow is trying to save NEW token → conflicts with invalidation
// By clearing the old token first, we ensure axios interceptor skips auto-refresh
const hadOldToken = !!localStorage.getItem('token');

if (hadOldToken) {
console.debug('[signInWithUrl] Clearing old token before processing OAuth callback to prevent race condition');
localStorage.removeItem('token');
}

try {
await verifyToken(accessToken);
} catch (e) {
console.warn('[signInWithUrl] Verify token failed', { message: (e as Error)?.message });
return Promise.reject({
code: -1,
message: 'Verify token failed',
Expand All @@ -324,6 +361,7 @@ export async function signInWithUrl(url: string) {
try {
await refreshToken(refresh_token);
} catch (e) {
console.warn('[signInWithUrl] Refresh token failed', { message: (e as Error)?.message });
return Promise.reject({
code: -1,
message: 'Refresh token failed',
Expand Down
6 changes: 3 additions & 3 deletions src/application/services/js-services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
UploadTemplatePayload,
} from '@/application/template.type';
import {
AccessLevel,
CreateFolderViewPayload,
CreatePagePayload,
CreateSpacePayload,
Expand All @@ -52,10 +53,9 @@ import {
UpdateSpacePayload,
UpdateWorkspacePayload,
UploadPublishNamespacePayload,
WorkspaceMember,
YjsEditorKey,
ViewIconType,
AccessLevel
WorkspaceMember,
YjsEditorKey
} from '@/application/types';
import { applyYDoc } from '@/application/ydoc/apply';

Expand Down
Loading