From 23eda381311ba6fba208b11117e904308fbd4674 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 17 Apr 2026 07:03:54 -0300 Subject: [PATCH] feat: Implement cookie handling with SameSite support and validation. --- .claude/CLAUDE.md | 30 ++ .claude/rules/documentation.md | 37 +++ .claude/rules/github-workflows.md | 78 ++++++ .claude/rules/php-code-style.md | 121 +++++++++ .claude/rules/php-domain.md | 96 +++++++ .claude/rules/php-testing.md | 120 ++++++++ .github/copilot-instructions.md | 11 + README.md | 107 ++++++++ src/Cookie.php | 257 ++++++++++++++++++ src/Internal/Cookies/CookieName.php | 34 +++ src/Internal/Cookies/CookieValue.php | 34 +++ .../ConflictingLifetimeAttributes.php | 21 ++ .../Exceptions/CookieNameIsInvalid.php | 21 ++ .../Exceptions/CookieValueIsInvalid.php | 22 ++ .../Exceptions/SameSiteNoneRequiresSecure.php | 21 ++ src/Internal/Response/InternalResponse.php | 15 +- src/Internal/Response/ResponseHeaders.php | 75 ++++- src/SameSite.php | 12 + tests/CookieTest.php | 243 +++++++++++++++++ tests/HeadersTest.php | 112 ++++++++ tests/ResponseWithCookiesTest.php | 105 +++++++ tests/SameSiteTest.php | 32 +++ 22 files changed, 1579 insertions(+), 25 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/rules/documentation.md create mode 100644 .claude/rules/github-workflows.md create mode 100644 .claude/rules/php-code-style.md create mode 100644 .claude/rules/php-domain.md create mode 100644 .claude/rules/php-testing.md create mode 100644 .github/copilot-instructions.md create mode 100644 src/Cookie.php create mode 100644 src/Internal/Cookies/CookieName.php create mode 100644 src/Internal/Cookies/CookieValue.php create mode 100644 src/Internal/Exceptions/ConflictingLifetimeAttributes.php create mode 100644 src/Internal/Exceptions/CookieNameIsInvalid.php create mode 100644 src/Internal/Exceptions/CookieValueIsInvalid.php create mode 100644 src/Internal/Exceptions/SameSiteNoneRequiresSecure.php create mode 100644 src/SameSite.php create mode 100644 tests/CookieTest.php create mode 100644 tests/ResponseWithCookiesTest.php create mode 100644 tests/SameSiteTest.php diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..3e73900 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Project + +PHP microservices platform. Hexagonal architecture (ports & adapters), DDD, CQRS. + +## Rules + +All coding standards, architecture, naming, testing, documentation, and OpenAPI conventions +are defined in `rules/`. Read the applicable rule files before generating any code or documentation. + +## Commands + +- `make test` — run tests with coverage. +- `make mutation-test` — run mutation testing (Infection). +- `make review` — run lint. +- `make help` — list all available commands. + +## Post-change validation + +After any code change, run `make review`, `make test`, and `make mutation-test`. +If any fails, iterate on the fix while respecting all project rules until all pass. +Never deliver code that breaks lint, tests, or leaves surviving mutants. + +## File formatting + +Every file produced or modified must: + +- Use **LF** line endings. Never CRLF. +- Have no trailing whitespace on any line. +- End with a single trailing newline. +- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..64587c9 --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,37 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## README + +1. Include an anchor-linked table of contents. +2. Start with a concise one-line description of what the library does. +3. Include a **badges** section (license, build status, coverage, latest version, PHP version). +4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. +5. **Installation** section: Composer command (`composer require vendor/package`). +6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example + includes a brief heading describing what it demonstrates. +7. If the library exposes multiple entry points, strategies, or container types, document each with its own + subsection and example. +8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users + frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) + followed by a concise explanation. Only include entries that address real confusion points. +9. **License** and **Contributing** sections at the end. +10. Write strictly in American English. See `rules/code-style.md` American English section for spelling conventions. + +## Structured data + +1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, + use tables with columns: Parameter, Type, Required, Description. +2. Prefer tables to prose for any structured information. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..a369ba4 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,78 @@ +--- +description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used +inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. + +## Pre-output checklist + +Verify every item before producing any workflow YAML. If any item fails, revise before outputting. + +1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. +2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash + (e.g., `CD — Run migration`, not `CD — Run Migration`). +3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. +4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. +5. Every input has a `description` field. Descriptions use American English and end with a period. +6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. +7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. +8. Choice input options are in **alphabetical order**. +9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. +10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). +11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. +12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, + `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. +13. All other YAML property names within a block are ordered by **name length ascending**. +14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. +15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. +16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, + `package.json`). No version is hardcoded in any workflow. +17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, + not in the workflows repository. +18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. +19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they + need. +20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. +21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. +22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. +23. Third-party actions are pinned to the latest available full commit SHA with a version comment: + `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest + version before generating a workflow. +24. First-party actions (`actions/*`) are pinned to the latest major version tag available: + `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. +25. Production deployments require GitHub Environments protection rules (manual approval). +26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 + minutes. Adjust only with justification in a comment. +27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid + redundant runs. +28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to + prevent interrupted deployments. +29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. +30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. + +## Style + +- All text (workflow names, step names, input descriptions, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +## Callers + +- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. +- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. +- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. + +## Image tagging + +- CD deploy builds: `-sha-` + `latest`. + +## Migrations + +- Migrations run **before** service deployment (schema first, code second). +- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. +- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-code-style.md b/.claude/rules/php-code-style.md new file mode 100644 index 0000000..59323ba --- /dev/null +++ b/.claude/rules/php-code-style.md @@ -0,0 +1,121 @@ +--- +description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# Code style + +Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` +and are not repeated here. Refer to `rules/domain.md` for domain modeling rules. + +## Pre-output checklist + +Verify every item before producing any PHP code. If any item fails, revise before outputting. + +1. `declare(strict_types=1)` is present. +2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is + designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without + `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). +3. All parameters, return types, and properties have explicit types. +4. Constructor property promotion is used. +5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). + Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, + `assertTrue`, `expectException`, etc.). +6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. +8. No generic identifiers exist. Use domain-specific names instead: + `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, + `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. +9. No raw arrays exist where a typed collection or value object is available. Use `tiny-blocks/collection` + (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable + only for primitive configuration data, variadic pass-through, or interop at system boundaries. +10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site + or extract it to a collaborator or value object. +11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each + group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have + no body, are ordered by name length ascending. +12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), + except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), + which takes precedence. The same rule applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. No O(N²) or worse complexity exists. +14. No logic is duplicated across two or more places (DRY). +15. No abstraction exists without real duplication or isolation need (KISS). +16. All identifiers, comments, and documentation are written in American English. +17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. +18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. + Never leave silent gaps. +19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. +20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not + need it, it does not exist. +22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, + first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods + over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), + and `Collection::map` over foreach accumulation. +23. No vertical alignment of types in parameter lists or property declarations. Use a single space between + type and variable name. Never pad with extra spaces to align columns: + `public OrderId $id` — not `public OrderId $id`. +24. Opening brace `{` goes on the same line as the closing parenthesis `)` for constructors, methods, and + closures: `): ReturnType {` — not `): ReturnType\n {`. Parameters with default values go last. + +## Casing conventions + +- Internal code (variables, methods, classes): **`camelCase`**. +- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, + `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**. + Prefer names that describe the domain operation. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## Comparisons + +1. Null checks: use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings + because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. + +## American English + +All identifiers, enum values, comments, and error codes use American English spelling: +`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), +`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), +`fulfill` (not `fulfil`), `color` (not `colour`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`. +- Never add PHPDoc to concrete classes. + +## Collection usage + +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions. + +**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** + +```php +$names = array_map( + static fn(Element $element): string => $element->name(), + iterator_to_array($collection) +); +``` + +**Correct — fluent chain with `map()` + `toArray()`:** + +```php +$names = $collection + ->map(transformations: static fn(Element $element): string => $element->name()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +The same applies to `filter()`, `reduce()`, `each()`, and all other `Collectible` operations. Chain them +fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` function. diff --git a/.claude/rules/php-domain.md b/.claude/rules/php-domain.md new file mode 100644 index 0000000..f3b0eea --- /dev/null +++ b/.claude/rules/php-domain.md @@ -0,0 +1,96 @@ +--- +description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID. +paths: + - "src/**/*.php" +--- + +# Domain modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. +Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +**Public API boundary:** Only interfaces, extension points, enums, and thin orchestration classes live at the +`src/` root. These classes define the contract consumers interact with and delegate all real work to collaborators +inside `src/Internal/`. If a class contains substantial logic (algorithms, state machines, I/O), it belongs in +`Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **domain concept** the library represents. + A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection + library uses `Collectible`, `Order`. +2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`, + `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`, + `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes. +3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`, + `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`, + `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation + paths exist. + +## Exceptions + +1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.). +2. Are pure: no formatted `code`/`message` for HTTP responses. +3. Signal invariant violations only. +4. Name after the invariant violated, never after the technical type: + `PrecisionOutOfRange` — not `InvalidPrecisionException`. + `CurrencyMismatch` — not `BadCurrencyException`. + `ContainerWaitTimeout` — not `TimeoutException`. +5. Create the exception class directly with the invariant name and the appropriate native parent. The exception + is dedicated by definition when its name describes the specific invariant it guards. + +## Enums + +1. Are PHP backed enums. +2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`). + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Principles + +- **Immutability**: all models and value objects adopt immutability. Operations return new instances. +- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O. +- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`. + +## SOLID reference + +| Principle | Failure signal | +|---------------------------|---------------------------------------------| +| S — Single responsibility | Class does two unrelated things | +| O — Open/closed | Adding a feature requires editing internals | +| L — Liskov substitution | Subclass throws on parent method | +| I — Interface segregation | Interface has unused methods | +| D — Dependency inversion | Constructor uses `new ConcreteClass()` | diff --git a/.claude/rules/php-testing.md b/.claude/rules/php-testing.md new file mode 100644 index 0000000..7bd9e68 --- /dev/null +++ b/.claude/rules/php-testing.md @@ -0,0 +1,120 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $five = Money::of(amount: 500, currency: Currency::BRL); + + /** @When adding them together */ + $total = $ten->add(other: $five); + + /** @Then the result contains the sum of both amounts */ + self::assertEquals(expected: 1500, actual: $total->amount()); +} +``` + +### Exception example + +When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this +ordering. + +```php +public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void +{ + /** @Given two money instances in different currencies */ + $brl = Money::of(amount: 1000, currency: Currency::BRL); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or +`@When` tags. + +## Rules + +1. Include exactly one `@When` per test. Two actions require two tests. +2. Test only the public API. Never assert on private state or `Internal/` classes directly. +3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, + clock, network) when the library interacts with external resources. +4. Name tests to describe behavior, not method names. +5. Never include conditional logic inside tests. +6. Include one logical concept per `@Then` block. +7. Maintain strict independence between tests. No inherited state. +8. For exception tests, place `@Then` (expectException) before `@When`. +9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +11. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class + for an internal model only when the condition cannot be reached through the public API. +12. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. +13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. + +## Test setup and fixtures + +1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line + followed by one expression or assignment. Never place multiple variable declarations or object + constructions under a single annotation. +2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the + call site. Chain method calls when the intermediate state is not referenced elsewhere + (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). +3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. + If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a + private method on the test class. +4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like + `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. +5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +## Test organization + +``` +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +│ └── Mocks/ # Alternative location for test doubles +├── Integration/ # Tests requiring real external resources (Docker, filesystem) +└── bootstrap.php # Test bootstrap when needed +``` + +- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API. +- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases). + Only present when the library interacts with infrastructure. +- `tests/Models/`: domain-specific fixture classes reused across test files. +- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries. + +## Coverage and mutation testing + +1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration + that exclude code from coverage are allowed. +2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` + or any other mechanism. +3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor + the code to make it testable, do not work around the tool. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77c2bb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Copilot instructions + +## Context + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## Mandatory pre-task step + +Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not +deviate from the patterns, folder structure, or naming conventions defined in them. diff --git a/README.md b/README.md index 08d2933..ec49240 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,113 @@ to the [PSR-7](https://www.php-fig.org/psr/psr-7) standard. ->withHeader(name: 'X-NAME', value: 'Xpto'); ``` +#### Setting cookies + +The library models the `Set-Cookie` HTTP response header through the `Cookie` value object, covering the full +[RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) attribute set plus modern additions such as `SameSite` and +`Partitioned`. Instances are immutable and fluent — every builder call returns a new `Cookie`. Like `ContentType` and +`CacheControl`, `Cookie` implements `Headers`, so it composes naturally with any `Response` factory via varargs. + +- **Setting a session cookie**: Build a cookie with the required security flags and attach it to a response. + + ```php + use TinyBlocks\Http\Cookie; + use TinyBlocks\Http\Response; + use TinyBlocks\Http\SameSite; + + $cookie = Cookie::create(name: 'refresh_token', value: $opaqueToken) + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800); + + Response::ok(body: ['ok' => true], $cookie); + ``` + +- **Setting multiple cookies**: Pass each `Cookie` as an additional header argument. The response emits one + `Set-Cookie` header per cookie, preserving all of them (this follows the PSR-7 multi-value header model). + + ```php + use TinyBlocks\Http\Cookie; + use TinyBlocks\Http\Response; + use TinyBlocks\Http\SameSite; + + $accessCookie = Cookie::create(name: 'access_token', value: $accessToken) + ->httpOnly() + ->secure() + ->withPath(path: '/'); + + $refreshCookie = Cookie::create(name: 'refresh_token', value: $refreshToken) + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800); + + Response::ok(body: ['ok' => true], $accessCookie, $refreshCookie); + ``` + +- **Expiring a cookie**: Use `Cookie::expire()` to instruct the browser to delete a previously set cookie. Chain the + same `Path` (and `Domain`, if applicable) used when the cookie was issued; otherwise the browser will not match the + cookie and the deletion will have no effect. + + ```php + use TinyBlocks\Http\Cookie; + use TinyBlocks\Http\Response; + use TinyBlocks\Http\SameSite; + + $expired = Cookie::expire(name: 'refresh_token') + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions'); + + Response::noContent($expired); + ``` + +- **Using an absolute expiration date**: When an explicit deletion moment is preferable over `Max-Age`, use + `withExpires()`. The date is converted to UTC and rendered in + the [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231) + date format required by the `Expires` attribute. `Max-Age` and `Expires` are mutually exclusive — setting both + throws `ConflictingLifetimeAttributes` when the response is serialized. + + ```php + use DateTimeImmutable; + use DateTimeZone; + use TinyBlocks\Http\Cookie; + + Cookie::create(name: 'preference', value: 'dark-mode')->withExpires( + expires: new DateTimeImmutable(datetime: '2030-01-15 12:00:00', timezone: new DateTimeZone(timezone: 'UTC')) + ); + ``` + +- **Cross-site cookies**: `SameSite::NONE` requires the `Secure` flag — modern browsers reject `SameSite=None` cookies + sent over insecure connections. The library enforces this invariant at serialization time and throws + `SameSiteNoneRequiresSecure` when the combination is incomplete. + + ```php + use TinyBlocks\Http\Cookie; + use TinyBlocks\Http\SameSite; + + Cookie::create(name: 'embed_session', value: $token) + ->withSameSite(sameSite: SameSite::NONE) + ->secure(); + ``` + +- **Validation at construction time**: Cookie names and values are validated against + [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265). Names cannot be empty nor contain control characters, + whitespace, or token separators (``( ) < > @ , ; : \ " / [ ] ? = { }``). Values cannot contain control characters, + whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before + passing it when it may contain arbitrary text. + + ```php + use TinyBlocks\Http\Cookie; + + Cookie::create(name: 'user_id', value: (string)$userId); # valid + Cookie::create(name: 'payload', value: base64_encode($jsonBody)); # encode arbitrary values first + ``` + #### Using the status code The library exposes a concrete implementation through the `Code` enum. You can retrieve the status codes, their diff --git a/src/Cookie.php b/src/Cookie.php new file mode 100644 index 0000000..b0156a9 --- /dev/null +++ b/src/Cookie.php @@ -0,0 +1,257 @@ +name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: true, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function httpOnly(): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: true, + partitioned: $this->partitioned + ); + } + + public function partitioned(): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: true + ); + } + + public function withPath(string $path): Cookie + { + return new Cookie( + name: $this->name, + path: $path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function withValue(string $value): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: CookieValue::from($value), + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function withDomain(string $domain): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function withMaxAge(int $seconds): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $seconds, + secure: $this->secure, + expires: $this->expires, + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function withExpires(DateTimeInterface $expires): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: DateTimeImmutable::createFromInterface($expires)->setTimezone(new DateTimeZone('UTC')), + sameSite: $this->sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function withSameSite(SameSite $sameSite): Cookie + { + return new Cookie( + name: $this->name, + path: $this->path, + value: $this->value, + domain: $this->domain, + maxAge: $this->maxAge, + secure: $this->secure, + expires: $this->expires, + sameSite: $sameSite, + httpOnly: $this->httpOnly, + partitioned: $this->partitioned + ); + } + + public function toArray(): array + { + $invariantViolation = match (true) { + $this->sameSite === SameSite::NONE && !$this->secure => new SameSiteNoneRequiresSecure(), + !is_null($this->maxAge) && !is_null($this->expires) => new ConflictingLifetimeAttributes(), + default => null, + }; + + if (!is_null($invariantViolation)) { + throw $invariantViolation; + } + + $parts = [sprintf('%s=%s', $this->name->toString(), $this->value->toString())]; + + if (!is_null($this->maxAge)) { + $parts[] = sprintf('Max-Age=%d', $this->maxAge); + } + + if (!is_null($this->expires)) { + $parts[] = sprintf('Expires=%s', $this->expires->format(self::EXPIRES_FORMAT)); + } + + if (!is_null($this->path)) { + $parts[] = sprintf('Path=%s', $this->path); + } + + if (!is_null($this->domain)) { + $parts[] = sprintf('Domain=%s', $this->domain); + } + + if ($this->secure) { + $parts[] = 'Secure'; + } + + if ($this->httpOnly) { + $parts[] = 'HttpOnly'; + } + + if (!is_null($this->sameSite)) { + $parts[] = sprintf('SameSite=%s', $this->sameSite->value); + } + + if ($this->partitioned) { + $parts[] = 'Partitioned'; + } + + return ['Set-Cookie' => [implode('; ', $parts)]]; + } +} diff --git a/src/Internal/Cookies/CookieName.php b/src/Internal/Cookies/CookieName.php new file mode 100644 index 0000000..dd0e375 --- /dev/null +++ b/src/Internal/Cookies/CookieName.php @@ -0,0 +1,34 @@ +@,;:\\\"/[]?={} \t"; + + private function __construct(private string $value) + { + } + + public static function from(string $value): CookieName + { + if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { + throw new CookieNameIsInvalid($value); + } + + if (strpbrk($value, self::TOKEN_SEPARATORS) !== false) { + throw new CookieNameIsInvalid($value); + } + + return new CookieName($value); + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/Internal/Cookies/CookieValue.php b/src/Internal/Cookies/CookieValue.php new file mode 100644 index 0000000..a1dc59d --- /dev/null +++ b/src/Internal/Cookies/CookieValue.php @@ -0,0 +1,34 @@ +value; + } +} diff --git a/src/Internal/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Exceptions/ConflictingLifetimeAttributes.php new file mode 100644 index 0000000..9befc6f --- /dev/null +++ b/src/Internal/Exceptions/ConflictingLifetimeAttributes.php @@ -0,0 +1,21 @@ + is invalid. A name must not be empty and must not contain control ', + 'characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.' + ); + + parent::__construct(sprintf($template, $name)); + } +} diff --git a/src/Internal/Exceptions/CookieValueIsInvalid.php b/src/Internal/Exceptions/CookieValueIsInvalid.php new file mode 100644 index 0000000..418b71c --- /dev/null +++ b/src/Internal/Exceptions/CookieValueIsInvalid.php @@ -0,0 +1,22 @@ + is invalid. A value must not contain control characters, whitespace, ', + 'double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or ', + 'Base64) before passing it.' + ); + + parent::__construct(sprintf($template, $value)); + } +} diff --git a/src/Internal/Exceptions/SameSiteNoneRequiresSecure.php b/src/Internal/Exceptions/SameSiteNoneRequiresSecure.php new file mode 100644 index 0000000..14142e9 --- /dev/null +++ b/src/Internal/Exceptions/SameSiteNoneRequiresSecure.php @@ -0,0 +1,21 @@ +headers, - ResponseHeaders::fromNameAndValue(name: $name, value: $value) - ); - return new InternalResponse( body: $this->body, code: $this->code, - headers: $headers, + headers: $this->headers->withReplaced(name: $name, value: $value), protocolVersion: $this->protocolVersion ); } public function withoutHeader(string $name): MessageInterface { - $headers = $this->headers->removeByName(name: $name); - return new InternalResponse( body: $this->body, code: $this->code, - headers: $headers, + headers: $this->headers->removeByName(name: $name), protocolVersion: $this->protocolVersion ); } public function withAddedHeader(string $name, $value): MessageInterface { - $headers = ResponseHeaders::fromNameAndValue(name: $name, value: $value); - return new InternalResponse( body: $this->body, code: $this->code, - headers: $headers, + headers: $this->headers->withAdded(name: $name, value: $value), protocolVersion: $this->protocolVersion ); } diff --git a/src/Internal/Response/ResponseHeaders.php b/src/Internal/Response/ResponseHeaders.php index da08f65..7df7172 100644 --- a/src/Internal/Response/ResponseHeaders.php +++ b/src/Internal/Response/ResponseHeaders.php @@ -16,23 +16,26 @@ private function __construct(private array $headers) public static function fromOrDefault(Headers ...$headers): ResponseHeaders { - $mappedHeaders = empty($headers) - ? [ContentType::applicationJson(charset: Charset::UTF_8)->toArray()] - : array_map(fn(Headers $header) => $header->toArray(), $headers); + if (empty($headers)) { + return new ResponseHeaders(headers: ContentType::applicationJson(charset: Charset::UTF_8)->toArray()); + } - return new ResponseHeaders(headers: array_merge([], ...$mappedHeaders)); - } + $merged = []; - public static function fromNameAndValue(string $name, mixed $value): ResponseHeaders - { - return new ResponseHeaders(headers: [$name => [$value]]); + foreach ($headers as $header) { + foreach ($header->toArray() as $name => $values) { + $merged[$name] = isset($merged[$name]) ? array_merge($merged[$name], $values) : $values; + } + } + + return new ResponseHeaders(headers: $merged); } public function getByName(string $name): array { - $headers = array_change_key_case($this->headers); + $key = $this->findKey(name: $name); - return $headers[strtolower($name)] ?? []; + return $key === null ? [] : $this->headers[$key]; } public function hasHeader(string $name): bool @@ -43,12 +46,45 @@ public function hasHeader(string $name): bool public function removeByName(string $name): ResponseHeaders { $headers = $this->headers; - $existingHeader = $this->getByName(name: $name); + $existingKey = $this->findKey(name: $name); + + if ($existingKey !== null) { + unset($headers[$existingKey]); + } + + return new ResponseHeaders(headers: $headers); + } + + public function withReplaced(string $name, mixed $value): ResponseHeaders + { + $headers = $this->headers; + $existingKey = $this->findKey(name: $name); + $targetKey = $existingKey ?? $name; + $headers[$targetKey] = [$value]; + + return new ResponseHeaders(headers: $headers); + } + + public function withAdded(string $name, mixed $value): ResponseHeaders + { + $headers = $this->headers; + $existingKey = $this->findKey(name: $name); + + if ($existingKey === null) { + $headers[$name] = [$value]; + + return new ResponseHeaders(headers: $headers); + } + + $existingValues = $headers[$existingKey]; - if (!empty($existingHeader)) { - unset($headers[$name]); + if (in_array($value, $existingValues, strict: true)) { + return new ResponseHeaders(headers: $headers); } + $existingValues[] = $value; + $headers[$existingKey] = $existingValues; + return new ResponseHeaders(headers: $headers); } @@ -56,4 +92,17 @@ public function toArray(): array { return $this->headers; } + + private function findKey(string $name): ?string + { + $lowered = strtolower($name); + + foreach (array_keys($this->headers) as $key) { + if (strtolower($key) === $lowered) { + return $key; + } + } + + return null; + } } diff --git a/src/SameSite.php b/src/SameSite.php new file mode 100644 index 0000000..7edd6b2 --- /dev/null +++ b/src/SameSite.php @@ -0,0 +1,12 @@ +toArray(); + + /** @Then the header should contain only the name and value */ + self::assertSame(['Set-Cookie' => ['session=abc']], $actual); + } + + public function testCreateCookieWithAllAttributes(): void + { + /** @Given a cookie composed with every supported attribute */ + $cookie = Cookie::create(name: 'refresh_token', value: 'opaque-value') + ->withMaxAge(seconds: 604800) + ->withPath(path: '/v1/sessions') + ->withDomain(domain: 'api.example.com') + ->secure() + ->httpOnly() + ->withSameSite(sameSite: SameSite::STRICT) + ->partitioned(); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then the header should include every attribute in the canonical order */ + $expected = 'refresh_token=opaque-value; Max-Age=604800; Path=/v1/sessions; ' + . 'Domain=api.example.com; Secure; HttpOnly; SameSite=Strict; Partitioned'; + self::assertSame(['Set-Cookie' => [$expected]], $actual); + } + + public function testExpireCookieEmitsEmptyValueAndMaxAgeZero(): void + { + /** @Given a cookie deletion for an existing name */ + /** @And the same path used when the cookie was issued */ + $cookie = Cookie::expire(name: 'refresh_token')->withPath(path: '/v1/sessions'); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then the header should instruct the browser to discard the cookie */ + self::assertSame(['Set-Cookie' => ['refresh_token=; Max-Age=0; Path=/v1/sessions']], $actual); + } + + public function testWithValueReturnsNewInstanceWithReplacedValue(): void + { + /** @Given a cookie with an initial value */ + $original = Cookie::create(name: 'session', value: 'initial'); + + /** @When a new value is assigned */ + $rotated = $original->withValue(value: 'rotated'); + + /** @Then the original instance remains unchanged */ + self::assertSame(['Set-Cookie' => ['session=initial']], $original->toArray()); + /** @And the new instance carries the replaced value */ + self::assertSame(['Set-Cookie' => ['session=rotated']], $rotated->toArray()); + } + + public function testWithExpiresRendersTheDateInRfcFormatInUtc(): void + { + /** @Given an expiration in a non-UTC timezone */ + $cookie = Cookie::create(name: 'session', value: 'abc')->withExpires( + expires: new DateTimeImmutable('2030-01-15 12:00:00', new DateTimeZone('America/Sao_Paulo')) + ); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then the Expires attribute should be converted to UTC and formatted per RFC 7231 */ + self::assertSame( + ['Set-Cookie' => ['session=abc; Expires=Tue, 15 Jan 2030 15:00:00 GMT']], + $actual + ); + } + + public function testBuilderMethodsReturnNewInstanceWithoutMutatingOriginal(): void + { + /** @Given a base cookie without the secure flag */ + $base = Cookie::create(name: 'session', value: 'abc'); + + /** @When the secure flag is applied */ + $secured = $base->secure(); + + /** @Then the base instance remains unchanged */ + self::assertSame(['Set-Cookie' => ['session=abc']], $base->toArray()); + /** @And the new instance has the secure flag applied */ + self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); + } + + public function testSameSiteNoneWithoutSecureThrows(): void + { + /** @Given a cookie set to SameSite=None without the Secure flag */ + $cookie = Cookie::create(name: 'session', value: 'abc')->withSameSite(sameSite: SameSite::NONE); + + /** @Then an exception indicating the missing Secure flag should be thrown */ + $this->expectException(SameSiteNoneRequiresSecure::class); + $this->expectExceptionMessage('Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.'); + + /** @When the header is serialized */ + $cookie->toArray(); + } + + public function testSameSiteNoneWithSecureIsAllowed(): void + { + /** @Given a cookie with SameSite=None combined with Secure */ + $cookie = Cookie::create(name: 'session', value: 'abc') + ->withSameSite(sameSite: SameSite::NONE) + ->secure(); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then both attributes should be present */ + self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $actual); + } + + public function testMaxAgeAndExpiresTogetherThrows(): void + { + /** @Given a cookie with both Max-Age and Expires assigned */ + $cookie = Cookie::create(name: 'session', value: 'abc') + ->withMaxAge(seconds: 3600) + ->withExpires(expires: new DateTimeImmutable('2030-01-15 12:00:00 UTC')); + + /** @Then an exception indicating conflicting lifetime attributes should be thrown */ + $this->expectException(ConflictingLifetimeAttributes::class); + $this->expectExceptionMessage('Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.'); + + /** @When the header is serialized */ + $cookie->toArray(); + } + + public function testEmptyValueIsAcceptedAsValid(): void + { + /** @Given an empty value */ + $cookie = Cookie::create(name: 'session', value: ''); + + /** @When the header is serialized */ + $actual = $cookie->toArray(); + + /** @Then the value should be rendered as empty */ + self::assertSame(['Set-Cookie' => ['session=']], $actual); + } + + public function testWithValueRejectsInvalidReplacement(): void + { + /** @Given a valid cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc'); + + /** @Then an exception indicating the value is invalid should be thrown */ + $this->expectException(CookieValueIsInvalid::class); + + /** @When the value is replaced with one containing forbidden characters */ + $cookie->withValue(value: 'has;semicolon'); + } + + public function testExpireValidatesTheName(): void + { + /** @Then an exception indicating the name is invalid should be thrown */ + $this->expectException(CookieNameIsInvalid::class); + $this->expectExceptionMessage('Cookie name is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'); + + /** @When expiring a cookie with an invalid name */ + Cookie::expire(name: 'bad name'); + } + + public function testCreateExposesInvalidValueMessage(): void + { + /** @Then an exception indicating the value is invalid should be thrown */ + $this->expectException(CookieValueIsInvalid::class); + $this->expectExceptionMessage('Cookie value is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.'); + + /** @When creating a cookie with the invalid value */ + Cookie::create(name: 'session', value: 'abc;def'); + } + + #[DataProvider('invalidNameProvider')] + public function testCreateCookieRejectsInvalidName(string $name): void + { + /** @Then an exception indicating the name is invalid should be thrown */ + $this->expectException(CookieNameIsInvalid::class); + + /** @When creating a cookie with the invalid name */ + Cookie::create(name: $name, value: 'value'); + } + + #[DataProvider('invalidValueProvider')] + public function testCreateCookieRejectsInvalidValue(string $value): void + { + /** @Then an exception indicating the value is invalid should be thrown */ + $this->expectException(CookieValueIsInvalid::class); + + /** @When creating a cookie with the invalid value */ + Cookie::create(name: 'session', value: $value); + } + + public static function invalidNameProvider(): array + { + return [ + 'Empty name' => [''], + 'Name with space' => ['session id'], + 'Name with semicolon' => ['session;'], + 'Name with equals' => ['session=value'], + 'Name with control character' => ["session\x00"], + 'Name with comma' => ['session,id'], + 'Name with double quote' => ['session"'], + 'Name with brackets' => ['session[]'], + ]; + } + + public static function invalidValueProvider(): array + { + return [ + 'Value with space' => ['abc def'], + 'Value with tab' => ["abc\tdef"], + 'Value with semicolon' => ['abc;def'], + 'Value with comma' => ['abc,def'], + 'Value with double quote' => ['abc"def'], + 'Value with backslash' => ['abc\\def'], + 'Value with control character' => ["abc\x00def"], + ]; + } +} diff --git a/tests/HeadersTest.php b/tests/HeadersTest.php index ccc7ea7..8757325 100644 --- a/tests/HeadersTest.php +++ b/tests/HeadersTest.php @@ -80,6 +80,118 @@ public function testResponseHeadersWithNoCustomHeader(): void self::assertSame([], $actual); } + public function testAddHeaderAppendsDistinctValuesToExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); + + /** @When a distinct value is added to the same header */ + $actual = $response->withAddedHeader(name: 'X-Trace', value: 'second'); + + /** @Then both values should be preserved in the original order */ + self::assertSame('first, second', $actual->getHeaderLine(name: 'X-Trace')); + self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); + } + + public function testAddHeaderCreatesHeaderWhenAbsent(): void + { + /** @Given an HTTP response without a custom header */ + $response = Response::noContent(); + + /** @When a value is added for the absent header */ + $actual = $response->withAddedHeader(name: 'X-Trace', value: 'only-value'); + + /** @Then the header should be created carrying the given value */ + self::assertSame(['only-value'], $actual->getHeader(name: 'X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], + $actual->getHeaders() + ); + } + + public function testAddHeaderIsCaseInsensitiveWhenMatchingExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); + + /** @When a value is added using a differently cased header name */ + $actual = $response->withAddedHeader(name: 'x-trace', value: 'second'); + + /** @Then the value should be appended preserving the original case of the header name */ + self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], + $actual->getHeaders() + ); + } + + public function testWithoutHeaderIsCaseInsensitive(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'value'); + + /** @When the header is removed using a differently cased name */ + $actual = $response->withoutHeader(name: 'x-trace'); + + /** @Then the header should no longer be present */ + self::assertFalse($actual->hasHeader(name: 'X-Trace')); + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + + public function testWithoutHeaderIsNoOpWhenHeaderIsAbsent(): void + { + /** @Given an HTTP response without the target header */ + $response = Response::noContent(); + + /** @When the missing header is requested to be removed */ + $actual = $response->withoutHeader(name: 'X-Trace'); + + /** @Then the headers should remain unchanged */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + + public function testReplaceHeaderCreatesHeaderWhenAbsent(): void + { + /** @Given an HTTP response without the target header */ + $response = Response::noContent(); + + /** @When the header is replaced (i.e., set) */ + $actual = $response->withHeader(name: 'X-Trace', value: 'value'); + + /** @Then the header should be created with the given value */ + self::assertSame(['value'], $actual->getHeader(name: 'X-Trace')); + } + + public function testReplaceHeaderIsCaseInsensitiveOnExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); + + /** @When the header is replaced using a differently cased name */ + $actual = $response->withHeader(name: 'x-trace', value: 'second'); + + /** @Then the original casing of the header name should be preserved and the value replaced */ + self::assertSame(['second'], $actual->getHeader(name: 'X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], + $actual->getHeaders() + ); + } + + public function testMergingMultipleHeadersCombinesEntries(): void + { + /** @Given a Cache-Control and a Content-Type header */ + $cacheControl = CacheControl::fromResponseDirectives(noStore: ResponseCacheDirectives::noStore()); + $contentType = ContentType::textPlain(); + + /** @When a response is created with both */ + $actual = Response::noContent($cacheControl, $contentType); + + /** @Then both headers should be present */ + self::assertSame(['no-store'], $actual->getHeader(name: 'Cache-Control')); + self::assertSame(['text/plain'], $actual->getHeader(name: 'Content-Type')); + } + public function testResponseWithCacheControl(): void { /** @Given a Cache-Control header with multiple directives */ diff --git a/tests/ResponseWithCookiesTest.php b/tests/ResponseWithCookiesTest.php new file mode 100644 index 0000000..f93aebb --- /dev/null +++ b/tests/ResponseWithCookiesTest.php @@ -0,0 +1,105 @@ +httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/') + ->withMaxAge(seconds: 604800); + + /** @When the response is built with the cookie */ + $response = Response::ok(['ok' => true], $cookie); + + /** @Then the Set-Cookie header should reflect the cookie configuration */ + self::assertSame( + ['session=abc; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Strict'], + $response->getHeader('Set-Cookie') + ); + } + + public function testResponseWithMultipleCookiesPreservesEachOne(): void + { + /** @Given an access cookie */ + $accessCookie = Cookie::create(name: 'access_token', value: 'aaa') + ->httpOnly() + ->secure() + ->withPath(path: '/'); + + /** @And a refresh cookie */ + $refreshCookie = Cookie::create(name: 'refresh_token', value: 'bbb') + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800); + + /** @When the response is built with both cookies */ + $response = Response::ok(['ok' => true], $accessCookie, $refreshCookie); + + /** @Then both Set-Cookie header values should be present */ + $setCookieHeaders = $response->getHeader('Set-Cookie'); + self::assertCount(2, $setCookieHeaders); + self::assertSame('access_token=aaa; Path=/; Secure; HttpOnly', $setCookieHeaders[0]); + self::assertSame( + 'refresh_token=bbb; Max-Age=604800; Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict', + $setCookieHeaders[1] + ); + } + + public function testResponseWithCookiesCoexistsWithOtherHeaders(): void + { + /** @Given a cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc')->httpOnly()->secure(); + + /** @And a content type */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And a cache control directive */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); + + /** @When the response is built with all of them */ + $response = Response::ok(['ok' => true], $contentType, $cacheControl, $cookie); + + /** @Then every header should be preserved */ + self::assertSame(['application/json; charset=utf-8'], $response->getHeader('Content-Type')); + self::assertSame(['no-cache'], $response->getHeader('Cache-Control')); + self::assertSame(['session=abc; Secure; HttpOnly'], $response->getHeader('Set-Cookie')); + } + + public function testResponseWithExpireCookieInstructsBrowserToDiscard(): void + { + /** @Given an expiration cookie with the same path used on set */ + $cookie = Cookie::expire(name: 'refresh_token') + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions'); + + /** @When a no-content response is built with the cookie */ + $response = Response::noContent($cookie); + + /** @Then the Set-Cookie header should instruct the browser to discard the cookie */ + self::assertSame( + ['refresh_token=; Max-Age=0; Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict'], + $response->getHeader('Set-Cookie') + ); + } +} diff --git a/tests/SameSiteTest.php b/tests/SameSiteTest.php new file mode 100644 index 0000000..7de269a --- /dev/null +++ b/tests/SameSiteTest.php @@ -0,0 +1,32 @@ +value; + + /** @Then the value should match the casing expected by the Set-Cookie header */ + self::assertSame($expected, $actual); + } + + public static function sameSiteValueProvider(): array + { + return [ + 'Lax strategy' => [SameSite::LAX, 'Lax'], + 'None strategy' => [SameSite::NONE, 'None'], + 'Strict strategy' => [SameSite::STRICT, 'Strict'], + ]; + } +}