Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3b940da
test(mpp): add RFC 3339 expires conformance corpus and wire the harne…
michael-moffett Aug 10, 2026
6b59cd4
test(mpp): add expires RFC 3339 conformance tests to seven SDK trees
michael-moffett Aug 10, 2026
e2f2b1d
test(mpp): correct the expires collector comment and cover its filter
michael-moffett Aug 10, 2026
7bd8674
test(mpp): re-encode the expires vectors in the canonical file shape
michael-moffett Aug 11, 2026
7752cf7
docs(mpp): add the RFC 3339 expires conformance results
michael-moffett Aug 11, 2026
a81ad60
chore(mpp): run prettier and rustfmt on the expires conformance tests
michael-moffett Aug 12, 2026
aed2638
docs(mpp): correct the direction of the typescript expires divergences
michael-moffett Aug 12, 2026
48ea8aa
Merge branch 'main' into p036-rfc3339-round3
michael-moffett Sep 8, 2026
6b47781
test(mpp): use the shared RFC 3339 parser in the expires corpus
michael-moffett Sep 8, 2026
19ac605
docs(mpp): re-derive expires conformance results on main
michael-moffett Sep 8, 2026
f4befc9
test(mpp): one corpus loop, comments to file norm
michael-moffett Sep 8, 2026
87c3f47
docs(mpp): report expires results at this sha
michael-moffett Sep 8, 2026
faf3250
docs(mpp): make the two typescript headline rows reproducible; reconc…
michael-moffett Sep 8, 2026
061154c
test(go): read the expires corpus verdict from time.Parse
michael-moffett Oct 9, 2026
6bab393
test(harness): replace year_0000 with year_0001
michael-moffett Oct 9, 2026
1b700fc
docs(mpp): re-run the expires counts after review
michael-moffett Oct 9, 2026
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
532 changes: 532 additions & 0 deletions docs/mpp-expires-rfc3339-conformance.md

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions go/protocols/mpp/wire/challenge_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package wire

import (
"encoding/json"
"os"
"testing"
"time"
)
Expand Down Expand Up @@ -161,3 +163,98 @@ func TestNewPaymentCredentialRejectsUnmarshalablePayload(t *testing.T) {
t.Fatal("expected marshal error")
}
}

// ── Cross-SDK RFC 3339 conformance corpus (issue #111) ──
//
// Vectors live in `harness/vectors/mpp-protocol/expires.json` under the
// `expires.parse` operation. Every SDK asserts the same ACCEPT / REJECT verdict
// against the same vectors, so a divergence between two SDKs shows up as a
// failing test in exactly one of them rather than as silence.
//
// Every scenario in the file is run. There is no slice to select and no
// scenario to skip.
//
// The verdict is read from `time.Parse` with `time.RFC3339`, the parser
// `IsExpired` calls. It is not read from `IsExpired` itself, which treats an
// empty `expires` as "no expiry" by design and so cannot report a parse
// failure for "".

const conformanceCorpusPath = "../../../../harness/vectors/mpp-protocol/expires.json"

type rfc3339Scenario struct {
Name string `json:"name"`
Description string `json:"description"`
Input string `json:"input"`
Tests json.RawMessage `json:"tests"`
}

type rfc3339Corpus struct {
Scenarios []rfc3339Scenario `json:"scenarios"`
}

// wantsAccept reports whether the scenario's `tests.parse` encodes ACCEPT.
// `true` is ACCEPT; an object carrying `success: false` is REJECT. Mirrors the
// encoding used by the other vector files in the same directory.
func (s rfc3339Scenario) wantsAccept(t *testing.T) bool {
t.Helper()
var probe struct {
Parse json.RawMessage `json:"parse"`
}
if err := json.Unmarshal(s.Tests, &probe); err != nil {
t.Fatalf("%s: tests block did not decode: %v", s.Name, err)
}
var accepted bool
if err := json.Unmarshal(probe.Parse, &accepted); err == nil {
return accepted
}
var rejection struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(probe.Parse, &rejection); err != nil {
t.Fatalf("%s: tests.parse was neither a bool nor an object: %v", s.Name, err)
}
return rejection.Success
}

// loadRFC3339Vectors returns every scenario in the corpus. The zero-length
// check guards the loader: a truncated or empty read fails here rather than
// passing quietly with nothing left to run.
func loadRFC3339Vectors(t *testing.T) []rfc3339Scenario {
t.Helper()
raw, err := os.ReadFile(conformanceCorpusPath)
if err != nil {
t.Fatalf("conformance corpus unreadable at %s: %v", conformanceCorpusPath, err)
}
var corpus rfc3339Corpus
if err := json.Unmarshal(raw, &corpus); err != nil {
t.Fatalf("conformance corpus did not decode: %v", err)
}
if len(corpus.Scenarios) == 0 {
t.Fatal("conformance corpus carried zero scenarios")
}
return corpus.Scenarios
}

func TestRFC3339ConformanceCorpus(t *testing.T) {
t.Parallel()
for _, scenario := range loadRFC3339Vectors(t) {
t.Run(scenario.Name, func(t *testing.T) {
t.Parallel()
_, err := time.Parse(time.RFC3339, scenario.Input)
accepted := err == nil
want := scenario.wantsAccept(t)
if accepted != want {
t.Fatalf("%s (%s): input %q — corpus expects %s, time.Parse reports %s",
scenario.Name, scenario.Description, scenario.Input,
conformanceVerdictName(want), conformanceVerdictName(accepted))
}
})
}
}

func conformanceVerdictName(accepted bool) string {
if accepted {
return "ACCEPT"
}
return "REJECT"
}
26 changes: 25 additions & 1 deletion harness/src/protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ base64url encode/decode, and the challenge-id HMAC.

| File | Role |
|------|------|
| `vectors.ts` | Loads the vendored canonical vectors and flattens them into dispatchable `ProtocolCase`s (handles the `tests.<dir>: true \| {success:false,error_type}` shape, constructed `wire` shorthand, `adapters` allow-lists, and `maxDurationMs*` budgets). |
| `vectors.ts` | Loads the vendored canonical vectors and flattens them into dispatchable `ProtocolCase`s (handles the `tests.<dir>: true \| {success:false,error_type}` shape, constructed `wire` shorthand, `adapters` allow-lists, and `maxDurationMs*` budgets). Also loads the RFC 3339 `expires` vectors via `collectExpiresCases()`. |
| `driver.ts` | Transport-agnostic case runner. `exact` compare for base64url / challenge.id; `semantic` compare for parse/format (re-parses both wires through the paired parse op, with credential request-encoding normalization) — mirrors the canonical runner's `compare_*_semantic`. Times the primary op and enforces the scenario's duration budget like `compare_duration`. |
| `flow-driver.ts` | HTTP 402 FLOW orchestration: a TypeScript port of the normative mpp-tools `flow_runner.py`. Performs the initial request / 402 / credential retry / receipt exchange itself, routing only `challenge.parse` / `credential.format` / `receipt.parse` through a `ProtocolAdapter`, and compares recorded results against `harness/vectors/mpp-protocol-flows/golden-results.json` after the canonical normalization. |
| `runners/typescript.ts` | TypeScript REFERENCE runner. In-process `ProtocolAdapter` over `mppx` (pay-kit's TS protocol core) + a stdin/stdout CLI speaking the canonical adapter ABI. |
Expand Down Expand Up @@ -47,6 +47,30 @@ Operations (canonical, from `mpp-tools/conformance/operations.json`):
`error_type` vocabulary: `parse_error` (`.parse`), `format_error` (`.format`),
`encoding_error` (base64url), `generation_error` (`challenge.id`).

### `expires.parse` — proposed by this PR, NOT canonical

| op | input | success result | compare |
|----|-------|----------------|---------|
| `expires.parse` | `{ expires }` | `{ valid: true }` | exact |

RFC 3339 `expires` parse conformance (issue #111), driven by
`harness/vectors/mpp-protocol/expires.json` through
`collectExpiresCases()`. **This operation is not in upstream
`mpp-tools/conformance/operations.json`** — it is proposed here so the corpus
has an operation to hang from, and the name, the input/result shape, and the
`exact` comparison are all the maintainer's to overrule. On rejection the
`error_type` is `parse_error`, reusing the `.parse` vocabulary above rather
than introducing a new one.

Every scenario in the file is an `expires` verdict and `collectExpiresCases()`
admits all of them. There is no slice to select and no scenario to skip.

No adapter implements `expires.parse` yet, so these cases are deliberately
**not** in `collectProtocolCases()` — folding them in would turn the green
reference suite red. Nothing calls `collectExpiresCases()` today: each SDK's
`expires` test loads the vector JSON itself. The collector is the TypeScript
side of that contract, kept for the day an adapter implements the op.

## Running

```
Expand Down
104 changes: 103 additions & 1 deletion harness/src/protocol/vectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ export const VECTORS_DIR = join(here, "..", "..", "vectors", "mpp-protocol");

// Canonical adapter-ABI operation identifiers. These match
// `conformance/operations.json` in mpp-tools verbatim.
//
// DIVERGENCE (this PR): `expires.parse` is the one member below that is NOT
// in upstream `conformance/operations.json`. It is proposed here, by this PR,
// to give the RFC 3339 `expires` corpus (issue #111) an operation to hang
// from; `operations.json` is not vendored in this repo (it lives upstream in
// tempoxyz/mpp-tools), so nothing here can be checked against it. The
// maintainer may want the operation added upstream, may want it named
// differently, or may want it kept as a pay-kit-local extension — all three
// are their call, and the name is trivially renameable (this union, the
// OPERATION_COMPARISON entry below, and `collectExpiresCases`). The rest of
// the union does still match upstream verbatim.
export type ProtocolOperation =
| "challenge.parse"
| "challenge.format"
Expand All @@ -24,7 +35,8 @@ export type ProtocolOperation =
| "receipt.format"
| "base64url.encode"
| "base64url.decode"
| "challenge.id";
| "challenge.id"
| "expires.parse";

// Comparison discipline per operation, taken from operations.json:
// exact -> result string/object must equal the golden byte-for-byte
Expand All @@ -42,6 +54,14 @@ export const OPERATION_COMPARISON: Record<ProtocolOperation, ComparisonMode> = {
"base64url.encode": "exact",
"base64url.decode": "exact",
"challenge.id": "exact",
// `exact`: an `expires.parse` success result is the fixed literal
// `{ valid: true }` (see ExpiresScenario below) — there is no header
// whitespace, param order, or request encoding for `semantic` to
// neutralize. `semantic` in this file is also *implemented* via
// `reparseWith`, which needs a paired `*.format` op; `expires.parse` has
// no `expires.format` counterpart, so `semantic` is not mechanically
// available to it. Same shape as base64url / challenge.id above.
"expires.parse": "exact",
};

// ── Raw vector file shapes ──
Expand Down Expand Up @@ -132,6 +152,25 @@ export type ChallengeIdScenario = {
expected: string;
};

// RFC 3339 `expires` scenario (issue #111). Same `{ name, description, tags,
// …, tests }` spine as every other scenario in this directory; the input field
// is named for its domain (`input`, a single untrusted string) the way header
// scenarios name theirs `wire` and base64url names its `decoded` / `encoded`.
//
// No `object` golden is carried for an ACCEPT: the file is deliberately a
// verdict corpus, not an instant-normalization corpus (leap seconds have no
// representable instant in most date libraries and sub-nanosecond precision
// truncates differently per language). #111 asks for ACCEPT/REJECT parity
// across SDKs and nothing more.
export type ExpiresScenario = {
name: string;
description?: string;
tags?: string[];
// The single untrusted string under test.
input: string;
tests: TestFlags;
};

type VectorFile<S> = {
version: string;
spec_ref: string;
Expand Down Expand Up @@ -164,6 +203,10 @@ export function loadChallengeId(): VectorFile<ChallengeIdScenario> {
return load<ChallengeIdScenario>("challenge-id.json");
}

export function loadExpires(): VectorFile<ExpiresScenario> {
return load<ExpiresScenario>("expires.json");
}

// A single dispatchable unit of work derived from a vector scenario: the
// op to run, the adapter input envelope, and either the golden success
// output (for a happy-path) or the expected error_type.
Expand Down Expand Up @@ -337,3 +380,62 @@ export function collectProtocolCases(): ProtocolCase[] {

return cases;
}

// Expand the RFC 3339 `expires` corpus into `expires.parse` cases (issue
// #111: assert ACCEPT and REJECT outcomes match across SDKs for each vector).
//
// Every scenario in the file is admitted. There is no slice to select: each row
// carries an `expires` verdict and nothing else does.
//
// ABI shape, PROPOSED BY THIS PR — the maintainer should overrule any of it:
// input : { expires: "<the untrusted string>" }
// (mirrors `{ header }` / `{ text }`: the field is named for what
// it holds)
// success : { valid: true }
// The corpus supplies no golden parsed value on purpose, so the
// success result carries the verdict itself rather than a
// normalized instant. Compared `exact` (see OPERATION_COMPARISON).
// failure : error_type "parse_error"
// NOT a new taxonomy: `parse_error` is the only `error_type` value
// that appears anywhere in this vectors directory (24/24
// occurrences), it is the documented vocabulary for `.parse` ops
// in `harness/src/protocol/README.md`, and the TypeScript
// reference runner already maps any `*.parse` throw to it
// (`runners/typescript.ts`, catch block). The corpus encodes it
// directly in each REJECT scenario's `tests.parse`.
//
// Deliberately NOT folded into `collectProtocolCases()`. That list is driven
// end-to-end against the TypeScript reference adapter by
// `test/protocol-conformance.test.ts`, and no adapter implements
// `expires.parse` yet — folding these in would add one red case per vector to
// a green suite. Nothing calls this collector today: each SDK's `expires` test
// loads the vector JSON itself. The collector is the TypeScript side of that
// contract, kept for the day an adapter implements `expires.parse` — at which
// point absorbing it is a one-line spread into `cases` above.
export function collectExpiresCases(): ProtocolCase[] {
const cases: ProtocolCase[] = [];

for (const s of loadExpires().scenarios) {
const input = { expires: s.input };
const parseErr = expectsError(s.tests.parse);
if (parseErr) {
cases.push({
op: "expires.parse",
scenario: s.name,
input,
expectSuccess: false,
errorType: parseErr,
});
} else if (expectsSuccess(s.tests.parse)) {
cases.push({
op: "expires.parse",
scenario: s.name,
input,
expectSuccess: true,
golden: { valid: true },
});
}
}

return cases;
}
80 changes: 80 additions & 0 deletions harness/test/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "../src/conformance/contract-schema";
import { discoverRunners } from "../src/conformance/runners";
import { parseLanguageAllowlist } from "../src/conformance/select";
import { collectExpiresCases, loadExpires } from "../src/protocol/vectors";
import type {
ConformanceVector,
RunnerResult,
Expand Down Expand Up @@ -360,3 +361,82 @@ describe("cross-SDK conformance vectors", () => {
});
}
});

// ── RFC 3339 `expires` corpus collector (issue #111) ──
//
// `collectExpiresCases()` (harness/src/protocol/vectors.ts) is the TypeScript
// side of the cross-SDK `expires` contract. No adapter implements
// `expires.parse` yet, so its cases sit deliberately outside
// `collectProtocolCases()` — folding them into the protocol driver would add
// one red case per vector to a green suite. That also means nothing dispatches
// the collector, so its ACCEPT/REJECT mapping would regress silently. It is
// asserted directly here instead.
//
// This block spawns no runner and contributes no vector to the driver above.
// It reads the vendored corpus and checks the collector's output against it.
// Every count is derived at runtime from the scenario list, so a corpus edit
// moves the expectation with it rather than turning this red for the wrong
// reason.
describe("mpp-protocol RFC 3339 expires corpus collector", () => {
const corpus = loadExpires();
const scenarios = corpus.scenarios;
const cases = collectExpiresCases();

it("emits a case for every scenario in the file", () => {
// A collector that silently degrades to emitting nothing, or that drops a
// scenario, fails here rather than reporting a smaller green suite.
expect(scenarios.length).toBeGreaterThan(0);
expect(cases).toHaveLength(scenarios.length);
expect(cases.map((testCase) => testCase.scenario).sort()).toEqual(
scenarios.map((scenario) => scenario.name).sort(),
);
});

it("tags every admitted case with the expires.parse op", () => {
for (const testCase of cases) {
expect(testCase.op, testCase.scenario).toBe("expires.parse");
}
});

it("maps every ACCEPT and REJECT verdict faithfully", () => {
const byScenario = new Map(cases.map((testCase) => [testCase.scenario, testCase]));

let accepts = 0;
let rejects = 0;
for (const scenario of scenarios) {
const testCase = byScenario.get(scenario.name);
expect(testCase, `no case emitted for ${scenario.name}`).toBeDefined();
if (!testCase) continue;

const parse = scenario.tests.parse;
if (parse === true) {
accepts += 1;
expect(testCase.expectSuccess, scenario.name).toBe(true);
if (testCase.expectSuccess) {
expect(testCase.golden, scenario.name).toEqual({ valid: true });
}
expect(testCase.input, scenario.name).toEqual({ expires: scenario.input });
continue;
}

expect(
typeof parse === "object" && parse.success === false,
`${scenario.name}: unexpected tests.parse encoding`,
).toBe(true);
if (typeof parse !== "object") continue;

rejects += 1;
expect(testCase.expectSuccess, scenario.name).toBe(false);
if (!testCase.expectSuccess) {
expect(testCase.errorType, scenario.name).toBe(parse.error_type);
}
expect(testCase.input, scenario.name).toEqual({ expires: scenario.input });
}

// Both verdicts must actually be represented: a mapping that collapsed one
// direction into the other would otherwise pass every assertion above.
expect(accepts).toBeGreaterThan(0);
expect(rejects).toBeGreaterThan(0);
expect(accepts + rejects).toBe(scenarios.length);
});
});
Loading
Loading