From 775cc83a2fca87cfed3011f8d27889b1c73f6120 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:52:12 +0700 Subject: [PATCH 1/3] fix(oauth): preserve consent failures --- .../src/components/layout/layout.context.tsx | 13 +++++++++--- .../layout/oauth-consent-unauthorized.ts | 11 ++++++++++ tests/bootstrap-oauth-consent-error.spec.ts | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/components/layout/oauth-consent-unauthorized.ts diff --git a/apps/frontend/src/components/layout/layout.context.tsx b/apps/frontend/src/components/layout/layout.context.tsx index 202da58045..b9eeaccd70 100644 --- a/apps/frontend/src/components/layout/layout.context.tsx +++ b/apps/frontend/src/components/layout/layout.context.tsx @@ -5,6 +5,7 @@ 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 { shouldPreserveOAuthConsentUnauthorized } from './oauth-consent-unauthorized'; export default function LayoutContext(params: { children: ReactNode }) { if (params?.children) { // eslint-disable-next-line react/no-children-prop @@ -80,7 +81,14 @@ function LayoutContextInner(params: { children: ReactNode }) { return true; } - if (response.status === 401 || response?.headers?.get('logout')) { + if ( + (response.status === 401 || response?.headers?.get('logout')) && + !shouldPreserveOAuthConsentUnauthorized( + url, + response.status, + Boolean(response?.headers?.get('logout')) + ) + ) { if (!isSecured) { setCookie('auth', '', -10); setCookie('showorg', '', -10); @@ -93,8 +101,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..499352718d --- /dev/null +++ b/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts @@ -0,0 +1,11 @@ +export function shouldPreserveOAuthConsentUnauthorized( + url: string, + status: number, + hasLogoutHeader: boolean +) { + return ( + !hasLogoutHeader && + status === 401 && + url.split('?', 1)[0] === '/oauth/authorize' + ); +} diff --git a/tests/bootstrap-oauth-consent-error.spec.ts b/tests/bootstrap-oauth-consent-error.spec.ts index 67c1f95b9f..32293623c6 100644 --- a/tests/bootstrap-oauth-consent-error.spec.ts +++ b/tests/bootstrap-oauth-consent-error.spec.ts @@ -2,6 +2,7 @@ import { authorizationActionResult, CONSENT_SESSION_ERROR, } from '../apps/frontend/src/app/(app)/oauth/authorize/authorization-action-result'; +import { 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 +20,23 @@ 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', 401, false) + ).toBe(true); + expect( + shouldPreserveOAuthConsentUnauthorized( + '/oauth/authorize?state=test', + 401, + false + ) + ).toBe(true); + expect( + shouldPreserveOAuthConsentUnauthorized('/user/profile', 401, false) + ).toBe(false); + expect( + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, true) + ).toBe(false); + }); }); From 9443ff23d611b47e7460afa484035f560cc4dc55 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:21:14 +0700 Subject: [PATCH 2/3] fix(oauth): preserve failed consent logout --- .../src/components/layout/layout.context.tsx | 31 ++++++++++++++++--- .../layout/oauth-consent-unauthorized.ts | 21 +++++++++++-- tests/bootstrap-oauth-consent-error.spec.ts | 29 ++++++++++++----- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/components/layout/layout.context.tsx b/apps/frontend/src/components/layout/layout.context.tsx index b9eeaccd70..efc81c849d 100644 --- a/apps/frontend/src/components/layout/layout.context.tsx +++ b/apps/frontend/src/components/layout/layout.context.tsx @@ -5,7 +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 { shouldPreserveOAuthConsentUnauthorized } from './oauth-consent-unauthorized'; +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 @@ -43,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); } @@ -52,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); @@ -82,11 +102,12 @@ function LayoutContextInner(params: { children: ReactNode }) { } if ( - (response.status === 401 || response?.headers?.get('logout')) && - !shouldPreserveOAuthConsentUnauthorized( + response.status === 401 || + shouldHandleGlobalLogout( url, + options.method, response.status, - Boolean(response?.headers?.get('logout')) + Boolean(logout) ) ) { if (!isSecured) { diff --git a/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts b/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts index 499352718d..cef98f0b4f 100644 --- a/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts +++ b/apps/frontend/src/components/layout/oauth-consent-unauthorized.ts @@ -1,11 +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 && - status === 401 && - url.split('?', 1)[0] === '/oauth/authorize' + hasLogoutHeader && + !shouldPreserveOAuthConsentUnauthorized(url, method, status) ); } diff --git a/tests/bootstrap-oauth-consent-error.spec.ts b/tests/bootstrap-oauth-consent-error.spec.ts index 32293623c6..b780628b12 100644 --- a/tests/bootstrap-oauth-consent-error.spec.ts +++ b/tests/bootstrap-oauth-consent-error.spec.ts @@ -2,7 +2,10 @@ import { authorizationActionResult, CONSENT_SESSION_ERROR, } from '../apps/frontend/src/app/(app)/oauth/authorize/authorization-action-result'; -import { shouldPreserveOAuthConsentUnauthorized } from '../apps/frontend/src/components/layout/oauth-consent-unauthorized'; +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', () => { @@ -23,20 +26,32 @@ describe('First-party OAuth consent error UI', () => { it('keeps a failed consent POST on the OAuth page instead of sending it to launches', () => { expect( - shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, false) + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 'POST', 401) ).toBe(true); expect( shouldPreserveOAuthConsentUnauthorized( - '/oauth/authorize?state=test', - 401, - false + 'https://beta-post.crove.com/oauth/authorize?state=test', + 'POST', + 401 ) ).toBe(true); expect( - shouldPreserveOAuthConsentUnauthorized('/user/profile', 401, false) + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 'GET', 401) + ).toBe(false); + expect( + shouldPreserveOAuthConsentUnauthorized('/user/profile', 'POST', 401) + ).toBe(false); + expect( + shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 'POST', 500) ).toBe(false); expect( - shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, true) + 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 + ); }); }); From 5a0b5bcc93d526535ab7e1af864af086c507d715 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:22:48 +0700 Subject: [PATCH 3/3] test(integration): add automated integration test runner with disposable container lifecycle and teardown --- scripts/test-integration.ps1 | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 scripts/test-integration.ps1 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 + } +}