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
71 changes: 71 additions & 0 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: CI - Unit Tests

on:
push:
branches:
- master
- 'feature/**'
- 'fix/**'
pull_request:
branches:
- master

jobs:
unit-tests:
name: Unit Tests (.NET 8)
runs-on: ubuntu-latest

permissions:
contents: read # checkout
checks: write # dorny/test-reporter — create check runs
pull-requests: write # dorny/test-reporter — annotate PRs

steps:
- name: Checkout repository
uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Shared root cause: checkout token persistence not disabled.
Both workflows should set with: persist-credentials: false on actions/checkout to reduce credential exposure in downstream steps.

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 19-20: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 20-20: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci-tests.yml at line 20, Add the checkout step option to
disable credential persistence: update the actions/checkout step that uses
"actions/checkout@v4" to include "with: persist-credentials: false" so the
checkout token is not exposed to downstream steps; ensure you apply the same
change to the other workflow(s) that call actions/checkout@v4 to keep behavior
consistent.

Source: Linters/SAST tools


⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List workflow files
echo "== Workflows =="
ls -la .github/workflows || true
echo

# Show lines around the referenced area in ci-tests.yml
echo "== ci-tests.yml (around lines 1-80) =="
nl -ba .github/workflows/ci-tests.yml | sed -n '1,120p'
echo

# Find all workflow files and grep for unpinned action uses (tag-only, major-only, etc.)
echo "== Unpinned action references (uses: owner/repo@v*) =="
rg -n "uses:\s*[^/@]+/[^/@]+@v[^#\s]+" .github/workflows || true
echo

echo "== Unpinned action references (uses: owner/repo@<major>.* without sha) =="
rg -n "uses:\s*[^/@]+/[^/@]+@v[0-9]+(?![0-9a-f]{7,40})" .github/workflows || true
echo

echo "== Pinned action references (uses with sha) =="
rg -n "uses:\s*[^/@]+/[^/@]+@[0-9a-f]{7,40}" .github/workflows || true
echo

# If there are multiple workflows, dump their uses blocks for quick inspection
echo "== Dump uses: lines per workflow =="
for f in .github/workflows/*.yml .github/workflows/*.yaml; do
  [ -e "$f" ] || continue
  echo "--- $f ---"
  rg -n "uses:\s*" "$f" || true
done

Repository: teesofttech/PayBridge

Length of output: 495


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Workflows =="
ls -la .github/workflows || true
echo

echo "== ci-tests.yml (show full file with line numbers) =="
cat -n .github/workflows/ci-tests.yml | sed -n '1,200p'
echo

echo "== integration-tests.yml (show full file with line numbers) =="
cat -n .github/workflows/integration-tests.yml | sed -n '1,260p'
echo

echo "== nuget-publish.yml (show full file with line numbers) =="
cat -n .github/workflows/nuget-publish.yml | sed -n '1,260p'
echo

echo "== Unpinned action references (uses: owner/repo@v* without sha) across workflows =="
# Matches uses: owner/repo@v<digits/letters> (common tags like v4, v3.1, v2) excluding sha-like 7-40 hex
rg -n --hidden --no-ignore-vcs "uses:\s*[^/@]+/[^/@]+@v[0-9][^#\s]*" .github/workflows || true

echo "== Uses references that look like they include a full/partial commit sha =="
rg -n --hidden --no-ignore-vcs "uses:\s*[^/@]+/[^/@]+@[0-9a-f]{7,40}" .github/workflows || true

echo "== All uses: lines across workflows =="
rg -n --hidden --no-ignore-vcs "^\s*uses:\s*" .github/workflows || true

Repository: teesofttech/PayBridge

Length of output: 11419


Pin GitHub Actions to immutable commit SHAs (avoid tag-based uses:).
CI/integration/release workflows use mutable major tags (no SHA), e.g. actions/checkout@v4, actions/setup-dotnet@v4, actions/upload-artifact@v4, dorny/test-reporter@v1, and softprops/action-gh-release@v2 (see .github/workflows/ci-tests.yml, .github/workflows/integration-tests.yml, .github/workflows/nuget-publish.yml). This creates a supply-chain risk if a tag is moved or a compromised action version is published.

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 19-20: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 20-20: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci-tests.yml at line 20, The workflow uses mutable action
tags (e.g., actions/checkout@v4) which should be replaced with pinned commit
SHAs to remove supply-chain risk; locate every "uses:" entry referencing
actions/checkout@v4, actions/setup-dotnet@v4, actions/upload-artifact@v4,
dorny/test-reporter@v1, and softprops/action-gh-release@v2 and update each to
the corresponding repository@<commit-sha> (fetch the latest stable commit SHA
from the action's GitHub repo), commit the updated uses: lines, and run/validate
the workflow to ensure syntax and behavior remain correct.

Source: Linters/SAST tools


- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore dependencies
run: dotnet restore PayBridge.SDK.sln

- name: Build solution
run: dotnet build PayBridge.SDK.sln --configuration Release --no-restore

- name: Run unit tests
run: |
dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \
--configuration Release \
--no-build \
--filter "Category=Unit" \
--logger "trx;LogFileName=unit-test-results.trx" \
--results-directory ./TestResults \
--collect:"XPlat Code Coverage"

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: unit-test-results
path: ./TestResults/*.trx

- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: ./TestResults/**/coverage.cobertura.xml

- name: Publish test results (PR comment)
uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Test Results
path: ./TestResults/*.trx
reporter: dotnet-trx
fail-on-error: true
fail-on-empty: false

96 changes: 96 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Integration Tests

on:
# Run manually from the Actions tab
workflow_dispatch:
inputs:
gateway:
description: 'Gateway to test (e.g. paystack, flutterwave, all)'
required: false
default: 'all'

# Also run nightly on master so regressions are caught early
schedule:
- cron: '0 2 * * *' # 02:00 UTC every day

jobs:
integration-tests:
name: Integration Tests (.NET 8)
runs-on: ubuntu-latest
environment: integration # Create this environment in repo Settings > Environments

permissions:
contents: read
checks: write
pull-requests: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore dependencies
run: dotnet restore PayBridge.SDK.sln

- name: Build solution
run: dotnet build PayBridge.SDK.sln --configuration Release --no-restore

- name: Run integration tests
env:
# ── Nigerian gateways ────────────────────────────────────────────────
PAYSTACK_SECRET_KEY: ${{ secrets.PAYSTACK_SECRET_KEY }}
FLUTTERWAVE_SECRET_KEY: ${{ secrets.FLUTTERWAVE_SECRET_KEY }}
MONNIFY_API_KEY: ${{ secrets.MONNIFY_API_KEY }}
MONNIFY_SECRET_KEY: ${{ secrets.MONNIFY_SECRET_KEY }}
MONNIFY_CONTRACT_CODE: ${{ secrets.MONNIFY_CONTRACT_CODE }}
SQUAD_SECRET_KEY: ${{ secrets.SQUAD_SECRET_KEY }}
SQUAD_IS_SANDBOX: 'true'
KORAPAY_SECRET_KEY: ${{ secrets.KORAPAY_SECRET_KEY }}
INTERSWITCH_CLIENT_ID: ${{ secrets.INTERSWITCH_CLIENT_ID }}
INTERSWITCH_CLIENT_SECRET: ${{ secrets.INTERSWITCH_CLIENT_SECRET }}
REMITA_MERCHANT_ID: ${{ secrets.REMITA_MERCHANT_ID }}
REMITA_API_KEY: ${{ secrets.REMITA_API_KEY }}
REMITA_SERVICE_TYPE_ID: ${{ secrets.REMITA_SERVICE_TYPE_ID }}
OPAY_MERCHANT_ID: ${{ secrets.OPAY_MERCHANT_ID }}
OPAY_PUBLIC_KEY: ${{ secrets.OPAY_PUBLIC_KEY }}
OPAY_PRIVATE_KEY: ${{ secrets.OPAY_PRIVATE_KEY }}
# ── African / global gateways ────────────────────────────────────────
DPO_COMPANY_TOKEN: ${{ secrets.DPO_COMPANY_TOKEN }}
DPO_SERVICE_TYPE: ${{ secrets.DPO_SERVICE_TYPE }}
PAWAPAY_API_TOKEN: ${{ secrets.PAWAPAY_API_TOKEN }}
PEACH_ENTITY_ID: ${{ secrets.PEACH_ENTITY_ID }}
PEACH_ACCESS_TOKEN: ${{ secrets.PEACH_ACCESS_TOKEN }}
# ── Global gateways ──────────────────────────────────────────────────
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
CHECKOUT_SECRET_KEY: ${{ secrets.CHECKOUT_SECRET_KEY }}
BENEFITPAY_MERCHANT_ID: ${{ secrets.BENEFITPAY_MERCHANT_ID }}
BENEFITPAY_API_KEY: ${{ secrets.BENEFITPAY_API_KEY }}
KNET_TRANSPORT_ID: ${{ secrets.KNET_TRANSPORT_ID }}
KNET_PASSWORD: ${{ secrets.KNET_PASSWORD }}
run: |
dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \
--configuration Release \
--no-build \
--filter "Category=Integration" \
--logger "trx;LogFileName=integration-test-results.trx" \
--results-directory ./TestResults

- name: Upload integration test results
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-test-results
path: ./TestResults/*.trx

- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Integration Test Results
path: ./TestResults/*.trx
reporter: dotnet-trx
fail-on-error: false # don't fail the workflow if sandbox keys are missing
71 changes: 71 additions & 0 deletions PayBridge.SDK.Test/Helpers/IntegrationTestBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Xunit;

namespace PayBridge.SDK.Test.Helpers;

/// <summary>
/// Base class for integration tests.
/// Any test class that derives from this will automatically be skipped
/// when required environment variables are missing, keeping CI output clean.
///
/// Usage:
/// <code>
/// public class PaystackIntegrationTests : IntegrationTestBase
/// {
/// public PaystackIntegrationTests()
/// : base("PAYSTACK_SECRET_KEY") { }
/// }
/// </code>
/// </summary>
public abstract class IntegrationTestBase
{
private readonly string[] _requiredVars;

/// <summary>
/// The reason string reported by xUnit when a test is skipped.
/// Set in the constructor if any env var is missing.
/// </summary>
protected string? SkipReason { get; }

/// <summary>
/// Whether all required env vars are present.
/// Use this in test bodies to conditionally Skip:
/// <code>Skip.If(ShouldSkip, SkipReason);</code>
/// </summary>
protected bool ShouldSkip => SkipReason != null;

protected IntegrationTestBase(params string[] requiredEnvVars)
{
_requiredVars = requiredEnvVars;
Comment on lines +21 to +38

var missing = requiredEnvVars
.Where(v => string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(v)))
.ToList();

if (missing.Count > 0)
{
SkipReason = $"Integration test skipped — missing env var(s): {string.Join(", ", missing)}";
}
}
Comment on lines +36 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

.env.test source is not loaded by this base class.

This implementation only reads process environment variables; it does not load values from .env.test, which is part of the stated integration-test objective. Please add explicit .env.test loading (or equivalent bootstrap) before missing-var evaluation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK.Test/Helpers/IntegrationTestBase.cs` around lines 36 - 48,
Modify the IntegrationTestBase constructor to load the .env.test file before
evaluating required env vars: call your dotenv loader (e.g.,
DotNetEnv.Load(".env.test") or equivalent safe loader) at the start of the
constructor (before computing missing) so Environment.GetEnvironmentVariable
reads values from .env.test; keep the existing _requiredVars assignment and then
compute missing and set SkipReason as before, and ensure the loader is tolerant
if .env.test is absent (no exception).

Comment on lines +36 to +48

/// <summary>
/// Reads a required env var. Throws if missing (only call after checking ShouldSkip).
/// </summary>
protected string GetRequiredEnv(string name)
{
var value = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException(
$"Required env var '{name}' is not set. Did you check ShouldSkip first?");
return value;
}

/// <summary>
/// Skips the current test if env vars are missing.
/// Call this at the top of every integration test method.
/// </summary>
protected void SkipIfMissingEnvVars()
{
if (ShouldSkip)
Skip.If(true, SkipReason!);
}
}
115 changes: 115 additions & 0 deletions PayBridge.SDK.Test/Helpers/MockHttpMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Net;
using System.Text;

namespace PayBridge.SDK.Test.Helpers;

/// <summary>
/// A fake HttpMessageHandler that returns pre-queued responses without hitting the network.
/// Supports multi-step gateways (e.g. Monnify/Interswitch that do OAuth then pay).
/// </summary>
public sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<MockHttpResponse> _queue = new();
private readonly List<HttpRequestMessage> _requests = new();

/// <summary>All requests made through this handler, in order.</summary>
public IReadOnlyList<HttpRequestMessage> Requests => _requests.AsReadOnly();

/// <summary>The most recent request made through this handler.</summary>
public HttpRequestMessage? LastRequest => _requests.Count > 0 ? _requests[^1] : null;

// ── Fluent setup ──────────────────────────────────────────────────────────

/// <summary>Queue a single JSON response.</summary>
public MockHttpMessageHandler RespondWith(
HttpStatusCode statusCode,
string jsonBody,
string contentType = "application/json")
{
_queue.Enqueue(new MockHttpResponse(statusCode, jsonBody, contentType));
return this;
}
Comment on lines +24 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Mock queue does not enforce expected HTTP method/URL per response.

The handler currently dequeues the next response for any incoming request, so a wrong endpoint/method can still pass if the queue is non-empty. That misses the acceptance criterion for queued (method, URL pattern, statusCode, responseBody) matching and “unexpected request” failures.

Also applies to: 33-41, 49-63

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PayBridge.SDK.Test/Helpers/MockHttpMessageHandler.cs` around lines 24 - 31,
The mock currently dequeues the next MockHttpResponse for any request; update
MockHttpMessageHandler and MockHttpResponse so each queued response includes
expected HttpMethod and URL pattern and make the request handler (SendAsync)
validate the incoming HttpRequestMessage against those expectations: if the head
response matches method and URL pattern then dequeue and return it, otherwise
throw an exception signaling an unexpected request (include actual request
method/URL and expected values). Also update the RespondWith overloads (the
methods that enqueue MockHttpResponse) to accept/record expected method and URL
pattern and ensure unit tests using RespondWith supply those values.


/// <summary>Queue a plain-text / XML response.</summary>
public MockHttpMessageHandler RespondWithText(
HttpStatusCode statusCode,
string body,
string contentType = "text/plain")
{
_queue.Enqueue(new MockHttpResponse(statusCode, body, contentType));
return this;
}

/// <summary>Queue an empty 200 OK response.</summary>
public MockHttpMessageHandler RespondWithOk()
=> RespondWith(HttpStatusCode.OK, "{}");

// ── Core override ─────────────────────────────────────────────────────────

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_requests.Add(request);

if (_queue.Count == 0)
{
throw new InvalidOperationException(
$"MockHttpMessageHandler: no queued response for {request.Method} {request.RequestUri}. " +
"Did you forget to call RespondWith()?");
}

var mock = _queue.Dequeue();
var response = new HttpResponseMessage(mock.StatusCode)
{
Content = new StringContent(mock.Body, Encoding.UTF8, mock.ContentType)
};
Comment on lines +49 to +66

return Task.FromResult(response);
}

// ── Assertion helpers ─────────────────────────────────────────────────────

/// <summary>Assert that the handler received exactly <paramref name="count"/> requests.</summary>
public void AssertRequestCount(int count)
{
if (_requests.Count != count)
throw new InvalidOperationException(
$"Expected {count} HTTP request(s) but got {_requests.Count}.");
}

/// <summary>Assert the last request targeted the expected URL path.</summary>
public void AssertLastRequestPath(string expectedPathContains)
{
var path = LastRequest?.RequestUri?.ToString() ?? "(none)";
if (!path.Contains(expectedPathContains, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Expected last request URL to contain '{expectedPathContains}' but was '{path}'.");
}

/// <summary>Assert the last request used the expected HTTP method.</summary>
public void AssertLastMethod(HttpMethod method)
{
if (LastRequest?.Method != method)
throw new InvalidOperationException(
$"Expected HTTP {method} but last request was {LastRequest?.Method}.");
}

/// <summary>
/// Build an <see cref="HttpClient"/> wired to this handler.
/// Optionally sets a base address.
/// </summary>
public HttpClient BuildClient(string? baseAddress = null)
{
var client = new HttpClient(this);
if (baseAddress != null)
client.BaseAddress = new Uri(baseAddress);
return client;
}
}

/// <summary>A single pre-configured HTTP response in the queue.</summary>
public sealed record MockHttpResponse(
HttpStatusCode StatusCode,
string Body,
string ContentType);
Loading
Loading