Skip to content

fix: check response.result instead of response.warnings for OnError.REJECT early-return#2976

Open
joonas wants to merge 3 commits intodefenseunicorns:mainfrom
joonas:fix/mutate-processor-use-result-over-warnings
Open

fix: check response.result instead of response.warnings for OnError.REJECT early-return#2976
joonas wants to merge 3 commits intodefenseunicorns:mainfrom
joonas:fix/mutate-processor-use-result-over-warnings

Conversation

@joonas
Copy link
Copy Markdown
Member

@joonas joonas commented Mar 1, 2026

Description

The early-return condition in mutateProcessor's binding loop used response?.warnings!.length > 0 to detect whether a rejection had occurred. Since response.warnings accumulates entries for every failed callback regardless of the onError mode the pre-existing warnings from a prior iteration could trigger a spurious early-return even when the current binding succeeded and no rejection was issued.

The ?. followed by ! was also contradictory: the optional chain guarded against response being nulli-sh while the non-null assertion claimed warnings was definitely defined, but response is always initialized on entry to mutateProcessor, making ?. unnecessary.

Replace the condition with response.result !== undefined. response.result is only set inside processRequest's catch block when config.onError === OnError.REJECT, making it the precise signal for rejection.

Add 4 tests covering the fix:

  • processRequest does not set response.result when the callback succeeds, even when the response carries pre-existing warnings
  • processRequest does not signal rejection when warnings pre-exist but the callback succeeds
  • mutateProcessor skips remaining bindings when the first fails under REJECT mode
  • mutateProcessor processes all bindings when all succeed under REJECT mode

End to End Test:
(See Pepr Excellent Examples)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Other (security config, docs update, etc)

Checklist before merging

…ror.REJECT` early-return

The early-return condition in `mutateProcessor`'s binding loop used
`response?.warnings!.length > 0` to detect whether a rejection had
occurred. This is the wrong signal — `response.warnings` accumulates
entries for every failed callback regardless of the `onError` mode,
so pre-existing warnings from a prior iteration could trigger a
spurious early-return even when the current binding succeeded and no
rejection was issued. The `?.` followed by `!` was also
contradictory: the optional chain guarded against `response` being
nullish while the non-null assertion claimed `warnings` was
definitely defined — but `response` is always initialized on entry
to `mutateProcessor`, making `?.` unnecessary.

Replace the condition with `response.result !== undefined`.
`response.result` is only set inside `processRequest`'s catch block
when `config.onError === OnError.REJECT`, making it the precise
signal for rejection.

Add 4 tests covering the fix:

- `processRequest` does not set `response.result` when the callback
  succeeds, even when the response carries pre-existing warnings
- `processRequest` does not signal rejection when warnings pre-exist
  but the callback succeeds
- `mutateProcessor` skips remaining bindings when the first fails
  under REJECT mode
- `mutateProcessor` processes all bindings when all succeed under
  REJECT mode

Signed-off-by: Joonas Bergius <joonas@defenseunicorns.com>
The `reencodeData` mock returned its input unchanged — an identity
function over the `PeprMutateRequest` wrapper. In production,
`reencodeData` returns `clone(wrapped.Raw)`, a `KubernetesObject`.
The identity mock let the "both succeed" test pass with an object
shape that never occurs in the real patch-generation path, reducing
confidence in the test.

Update the mock to `(wrapped) => clone(wrapped.Raw)`, matching
production behavior. Also reword test comments that overstated a
runtime scenario — they described warnings accumulating "from
earlier binding iterations," but under `OnError.REJECT` the loop
returns immediately on the first rejection so warnings never carry
over. The comments now focus on `processRequest`'s contract: it
treats the response as an in/out parameter and does not clear
pre-existing warnings on success.

Signed-off-by: Joonas Bergius <joonas@defenseunicorns.com>
@joonas joonas requested a review from a team as a code owner March 1, 2026 03:27
@samayer12
Copy link
Copy Markdown
Contributor

@greptileai

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

This PR fixes a spurious early-return in mutateProcessor's binding loop by replacing the imprecise response?.warnings!.length > 0 guard with response.result !== undefined. response.result is the precise, purpose-built signal for REJECT-mode failures (set only in processRequest's catch block under OnError.REJECT), whereas warnings accumulates across all binding iterations regardless of mode, making it an unreliable proxy. The contradictory ?. / ! operator pair is also removed. Four new unit/integration tests are added to document and protect the corrected invariant.

Confidence Score: 5/5

Safe to merge — the fix is minimal, precise, and well-tested; only a minor test redundancy remains.

The one-line production change is clearly correct: response.result is the only field set exclusively on REJECT-mode failure, making it a more accurate guard than warnings.length. All four new tests protect the right invariants, and the pre-existing test suite is unaffected. The sole finding is a P2 style note about two duplicate unit tests.

No files require special attention.

Important Files Changed

Filename Overview
src/lib/processors/mutate-processor.ts One-line fix: replaces the imprecise response?.warnings!.length > 0 early-return guard with the semantically correct response.result !== undefined, which is only set in the REJECT catch path. Also removes the contradictory ?. / ! pair since response is always initialized.
src/lib/processors/mutate-processor.test.ts Adds reencodeData to the decode-utils mock (needed for the new integration path) and four new tests covering the fix; two processRequest unit tests are redundant but otherwise coverage is solid.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[mutateProcessor: for each bindable] --> B[processRequest bindable, wrapped, response]
    B --> C{callback throws?}
    C -- No --> D[Log success]
    C -- Yes --> E[response.warnings.push error]
    E --> F{config.onError}
    F -- REJECT --> G[response.result = 'Pepr module configured to reject on error']
    F -- AUDIT --> H[response.auditAnnotations update]
    G --> J{OLD: warnings.length > 0?}
    D --> J
    H --> J
    J -- Yes under REJECT ❌ spurious --> K[Early return]
    J -- No --> L[Next binding]
    G --> N{NEW: response.result !== undefined?}
    D --> N2[response.result still undefined]
    N -- Yes ✅ precise --> K2[Early return]
    N2 --> L2[Next binding]
    style J fill:#f99,color:#000
    style N fill:#9f9,color:#000
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into fix/mutate-proc..." | Re-trigger Greptile

Comment on lines +384 to +448
describe("OnError.REJECT early-return checks response.result", () => {
describe("processRequest with pre-existing warnings", () => {
it("should not set response.result when callback succeeds, even with pre-existing warnings", async () => {
const successCallback = vi.fn();
const testBinding = { ...clone(defaultBinding), mutateCallback: successCallback };
const testBindable: Bindable = {
...clone(defaultBindable),
binding: testBinding,
config: { ...defaultModuleConfig, onError: OnError.REJECT },
};

// processRequest accepts an existing response and appends to it.
// Pre-existing warnings must not cause it to set response.result.
const responseWithPreExistingWarnings: MutateResponse = {
uid: "test-uid",
allowed: false,
warnings: ["warning from a prior binding failure"],
};

const result = await processRequest(
testBindable,
defaultPeprMutateRequest(),
responseWithPreExistingWarnings,
);

// The callback succeeded — no rejection occurred.
expect(successCallback).toHaveBeenCalled();
expect(result.response.result).toBeUndefined();

// Pre-existing warnings survive untouched.
expect(result.response.warnings).toContain("warning from a prior binding failure");
});

it("should not signal rejection when warnings pre-exist but callback succeeds", async () => {
// A successful callback must not set response.result, regardless of
// whether the response already carries warnings.
const successCallback = vi.fn();
const testBinding = { ...clone(defaultBinding), mutateCallback: successCallback };
const testBindable: Bindable = {
...clone(defaultBindable),
binding: testBinding,
config: { ...defaultModuleConfig, onError: OnError.REJECT },
};

const responseWithWarnings: MutateResponse = {
uid: "test-uid",
allowed: false,
warnings: ["prior warning"],
};

const { response } = await processRequest(
testBindable,
defaultPeprMutateRequest(),
responseWithWarnings,
);

// Warnings survive, but result is not set — no rejection occurred.
expect(response.warnings).toContain("prior warning");
expect(response.result).toBeUndefined();
});
});

describe("mutateProcessor with two bindings under REJECT", () => {
beforeEach(() => {
vi.clearAllMocks();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Duplicate test cases in the same describe block

The two tests inside processRequest with pre-existing warnings are functionally identical: both create a successCallback, build a response pre-seeded with one warning, call processRequest, and assert that result.response.result is undefined while the prior warning survives. Neither test exercises a distinct scenario — the only difference is variable naming (responseWithPreExistingWarnings vs responseWithWarnings). One of them can be removed without any loss of coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants