diff --git a/apps/frontend/src/components/layout/layout.context.tsx b/apps/frontend/src/components/layout/layout.context.tsx index 202da58045..efc81c849d 100644 --- a/apps/frontend/src/components/layout/layout.context.tsx +++ b/apps/frontend/src/components/layout/layout.context.tsx @@ -5,6 +5,10 @@ import { FetchWrapperComponent } from '@gitroom/helpers/utils/custom.fetch'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useReturnUrl } from '@gitroom/frontend/app/(app)/auth/return.url.component'; import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { + shouldHandleGlobalLogout, + shouldPreserveOAuthConsentUnauthorized, +} from './oauth-consent-unauthorized'; export default function LayoutContext(params: { children: ReactNode }) { if (params?.children) { // eslint-disable-next-line react/no-children-prop @@ -42,6 +46,15 @@ function LayoutContextInner(params: { children: ReactNode }) { response?.headers?.get('Impersonate'); const logout = response?.headers?.get('logout') || response?.headers?.get('Logout'); + if ( + shouldPreserveOAuthConsentUnauthorized( + url, + options.method, + response.status + ) + ) { + return true; + } if (headerAuth) { setCookie('auth', headerAuth, 365); } @@ -51,7 +64,15 @@ function LayoutContextInner(params: { children: ReactNode }) { if (impersonate) { setCookie('impersonate', impersonate, 365); } - if (logout && !isSecured) { + if ( + shouldHandleGlobalLogout( + url, + options.method, + response.status, + Boolean(logout) + ) && + !isSecured + ) { setCookie('auth', '', -10); setCookie('showorg', '', -10); setCookie('impersonate', '', -10); @@ -80,7 +101,15 @@ function LayoutContextInner(params: { children: ReactNode }) { return true; } - if (response.status === 401 || response?.headers?.get('logout')) { + if ( + response.status === 401 || + shouldHandleGlobalLogout( + url, + options.method, + response.status, + Boolean(logout) + ) + ) { if (!isSecured) { setCookie('auth', '', -10); setCookie('showorg', '', -10); @@ -93,8 +122,7 @@ function LayoutContextInner(params: { children: ReactNode }) { await deleteDialog( 'You are currently on trial, in order to use the feature you must finish the trial', 'Finish the trial, charge me now', - 'Trial', - + 'Trial' ) ) { window.open('/billing?finishTrial=true', '_blank'); diff --git a/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts b/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts new file mode 100644 index 0000000000..cef98f0b4f --- /dev/null +++ b/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts @@ -0,0 +1,26 @@ +export function shouldPreserveOAuthConsentUnauthorized( + url: string, + method: string | undefined, + status: number +) { + if (status !== 401 || method?.toUpperCase() !== 'POST') return false; + try { + return ( + new URL(url, 'https://oauth.invalid').pathname === '/oauth/authorize' + ); + } catch { + return false; + } +} + +export function shouldHandleGlobalLogout( + url: string, + method: string | undefined, + status: number, + hasLogoutHeader: boolean +) { + return ( + hasLogoutHeader && + !shouldPreserveOAuthConsentUnauthorized(url, method, status) + ); +} diff --git a/scripts/test-integration.ps1 b/scripts/test-integration.ps1 new file mode 100644 index 0000000000..2ff3bf4444 --- /dev/null +++ b/scripts/test-integration.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Automated integration test runner with disposable Docker test containers and guaranteed teardown. +.DESCRIPTION + 1. Spawns isolated PostgreSQL and Redis test containers on random high ports + 2. Runs Prisma migrations and executes Jest bootstrap integration tests + 3. Guarantees cleanup (docker rm -f) of all test containers in a finally block +.EXAMPLE + .\scripts\test-integration.ps1 +#> + +[CmdletBinding()] +param ( + [Parameter()] + [int]$PgPort = 15491, + + [Parameter()] + [int]$RedisPort = 16391, + + [Parameter()] + [switch]$KeepContainers +) + +$ErrorActionPreference = "Stop" + +$Timestamp = Get-Date -Format "yyyyMMddHHmmss" +$PgContainer = "crove-test-pg-$Timestamp" +$RedisContainer = "crove-test-redis-$Timestamp" + +Write-Host "==========================================================" -ForegroundColor Cyan + Write-Host " CROVE POST - INTEGRATION TEST RUNNER (AUTO-TEARDOWN) " -ForegroundColor Yellow +Write-Host "==========================================================" -ForegroundColor Cyan + +try { + Write-Host "`n[1/4] Starting disposable test containers..." -ForegroundColor Green + docker run -d --name $PgContainer -p "127.0.0.1:${PgPort}:5432" -e POSTGRES_PASSWORD=postiz-password -e POSTGRES_USER=postiz-user -e POSTGRES_DB=postiz-db-local postgres:17-alpine | Out-Null + docker run -d --name $RedisContainer -p "127.0.0.1:${RedisPort}:6379" redis:7.2 | Out-Null + + Write-Host "Waiting for database readiness on port ${PgPort}..." -ForegroundColor DarkGray + $attempts = 0 + $ready = $false + while ($attempts -lt 30 -and -not $ready) { + Start-Sleep -Seconds 1 + $res = docker exec $PgContainer pg_isready -U postiz-user -d postiz-db-local 2>&1 + if ($LASTEXITCODE -eq 0) { + $ready = $true + } + $attempts++ + } + + if (-not $ready) { + throw "PostgreSQL test container failed to become healthy within 30 seconds." + } + Write-Host "-> Test containers ready: $PgContainer (port $PgPort), $RedisContainer (port $RedisPort)" -ForegroundColor Green + + # Set temporary environment variables for integration tests + $env:DATABASE_URL = "postgresql://postiz-user:postiz-password@127.0.0.1:${PgPort}/postiz-db-local" + $env:DATABASE_DIRECT_URL = "postgresql://postiz-user:postiz-password@127.0.0.1:${PgPort}/postiz-db-local" + $env:REDIS_URL = "redis://127.0.0.1:${RedisPort}" + $env:JWT_SECRET = "test-jwt-secret-key-32-chars-minimum-length-ok" + + Write-Host "`n[2/4] Pushing Prisma schema to test database..." -ForegroundColor Green + pnpm dlx prisma@6.5.0 db push --accept-data-loss --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma --skip-generate + if ($LASTEXITCODE -ne 0) { + throw "Prisma db push to test database failed." + } + + Write-Host "`n[3/4] Running Jest integration test suite..." -ForegroundColor Green + pnpm exec jest --config tests/bootstrap.jest.cjs --runInBand --no-cache + if ($LASTEXITCODE -ne 0) { + throw "Integration tests failed." + } + + Write-Host "`n[4/4] All integration tests PASSED successfully!" -ForegroundColor Green + +} catch { + Write-Error "Test execution failed: $_" +} finally { + if (-not $KeepContainers) { + Write-Host "`n[Teardown] Cleaning up disposable test containers..." -ForegroundColor DarkYellow + docker rm -f $PgContainer 2>$null | Out-Null + docker rm -f $RedisContainer 2>$null | Out-Null + Write-Host "-> Successfully removed test containers: $PgContainer, $RedisContainer" -ForegroundColor DarkGray + } else { + Write-Host "`n[Notice] Preserving test containers (-KeepContainers specified)." -ForegroundColor Yellow + } +} diff --git a/tests/bootstrap-oauth-consent-error.spec.ts b/tests/bootstrap-oauth-consent-error.spec.ts index 67c1f95b9f..b780628b12 100644 --- a/tests/bootstrap-oauth-consent-error.spec.ts +++ b/tests/bootstrap-oauth-consent-error.spec.ts @@ -2,6 +2,10 @@ import { authorizationActionResult, CONSENT_SESSION_ERROR, } from '../apps/frontend/src/app/(app)/oauth/authorize/authorization-action-result'; +import { + shouldHandleGlobalLogout, + shouldPreserveOAuthConsentUnauthorized, +} from '../apps/frontend/src/components/layout/oauth-consent-unauthorized'; describe('First-party OAuth consent error UI', () => { it('keeps superseded tab A on a visible safe error instead of redirecting', () => { @@ -19,4 +23,35 @@ describe('First-party OAuth consent error UI', () => { redirect: null, }); }); + + it('keeps a failed consent POST on the OAuth page instead of sending it to launches', () => { + expect( + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 'POST', 401) + ).toBe(true); + expect( + shouldPreserveOAuthConsentUnauthorized( + 'https://beta-post.crove.com/oauth/authorize?state=test', + 'POST', + 401 + ) + ).toBe(true); + expect( + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 'GET', 401) + ).toBe(false); + expect( + shouldPreserveOAuthConsentUnauthorized('/user/profile', 'POST', 401) + ).toBe(false); + expect( + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 'POST', 500) + ).toBe(false); + expect( + shouldHandleGlobalLogout('/oauth/authorize', 'POST', 401, true) + ).toBe(false); + expect(shouldHandleGlobalLogout('/oauth/authorize', 'GET', 401, true)).toBe( + true + ); + expect(shouldHandleGlobalLogout('/user/profile', 'POST', 401, true)).toBe( + true + ); + }); });