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
36 changes: 32 additions & 4 deletions apps/frontend/src/components/layout/layout.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Comment on lines +49 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The options parameter in afterRequest can be undefined or null if the fetch wrapper is called without options (e.g., for simple GET requests). Accessing options.method directly will throw a TypeError: Cannot read properties of undefined (reading 'method') and crash the application layout. Use optional chaining (options?.method) to safely access the method.

Suggested change
if (
shouldPreserveOAuthConsentUnauthorized(
url,
options.method,
response.status
)
) {
return true;
}
if (
shouldPreserveOAuthConsentUnauthorized(
url,
options?.method,
response.status
)
) {
return true;
}

if (headerAuth) {
setCookie('auth', headerAuth, 365);
}
Expand All @@ -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
) {
Comment on lines +67 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing options.method directly can throw a TypeError if options is undefined. Use optional chaining (options?.method) to ensure safe property access.

Suggested change
if (
shouldHandleGlobalLogout(
url,
options.method,
response.status,
Boolean(logout)
) &&
!isSecured
) {
if (
shouldHandleGlobalLogout(
url,
options?.method,
response.status,
Boolean(logout)
) &&
!isSecured
) {

setCookie('auth', '', -10);
setCookie('showorg', '', -10);
setCookie('impersonate', '', -10);
Expand Down Expand Up @@ -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)
)
) {
Comment on lines +104 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing options.method directly can throw a TypeError if options is undefined. Use optional chaining (options?.method) to ensure safe property access.

Suggested change
if (
response.status === 401 ||
shouldHandleGlobalLogout(
url,
options.method,
response.status,
Boolean(logout)
)
) {
if (
response.status === 401 ||
shouldHandleGlobalLogout(
url,
options?.method,
response.status,
Boolean(logout)
)
) {

if (!isSecured) {
setCookie('auth', '', -10);
setCookie('showorg', '', -10);
Expand All @@ -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');
Expand Down
26 changes: 26 additions & 0 deletions apps/frontend/src/components/layout/oauth-consent-unauthorized.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
87 changes: 87 additions & 0 deletions scripts/test-integration.ps1
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +36 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In PowerShell, external commands (like docker run) do not throw script-terminating errors when they fail, even with $ErrorActionPreference = "Stop". If docker run fails (e.g., due to port conflicts or Docker daemon not running), the script will continue and wait 30 seconds for PostgreSQL to become ready, leading to a slow and confusing failure. Check $LASTEXITCODE immediately after each docker run command to fail fast.

    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
    if ($LASTEXITCODE -ne 0) { throw "Failed to start PostgreSQL container." }
    docker run -d --name $RedisContainer -p "127.0.0.1:${RedisPort}:6379" redis:7.2 | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "Failed to start Redis container." }


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
}
}
35 changes: 35 additions & 0 deletions tests/bootstrap-oauth-consent-error.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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
);
});
});
Loading