diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b91a36e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +# Publishes the gem to RubyGems.org via Trusted Publishing (OIDC) when a +# version tag is pushed. No API tokens are stored: GitHub Actions authenticates +# to RubyGems.org with a short-lived, scoped token. +# +# Prerequisites (one-time, on RubyGems.org): +# - A trusted publisher configured for this gem with: +# owner: devandreacarratta +# repository: password-forge-ruby-gem +# workflow: release.yml +# environment: release +# - For the very first publish (gem does not yet exist), configure a +# "pending trusted publisher" from your RubyGems.org profile first. + +on: + push: + tags: + - "v*" + +jobs: + push: + runs-on: ubuntu-latest + + permissions: + contents: write + id-token: write + + # Must match the environment configured on the RubyGems trusted publisher. + environment: release + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: ruby + + - name: Publish to RubyGems + uses: rubygems/release-gem@v1 diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md new file mode 100644 index 0000000..53bdb95 --- /dev/null +++ b/.kiro/steering/product.md @@ -0,0 +1,46 @@ +# Product + +## What this is + +`password_forge` is a configurable password generator distributed as a Ruby gem. +It generates random passwords from four selectable character sets — uppercase, +lowercase, numeric and special — that can be switched on or off independently. +All sets are enabled by default, and an error is raised if every set is +disabled. Randomness comes from Ruby's `SecureRandom`. + +The public API mirrors the design of an existing C# / NuGet package: a +constructor with four boolean flags plus a length, and a dedicated exception +when no character set is selected. + +## Goals + +1. **Learning vehicle.** This is the author's first Ruby gem. Code favours + clarity and idiomatic Ruby over cleverness, and everything is built with TDD. +2. **Kiro evangelism in the Ruby world.** Beyond the gem itself, the project + ships a full "Kiro-native" experience for Ruby gem authors: Kiro skills, + project steering (this folder), hooks, and an MCP server. The repository is + meant to be a reference example of building and shipping a gem the Kiro way. + +## Audience + +- Ruby developers who need a small, dependency-free password generator. +- Ruby developers curious about using Kiro to build and release gems. + +## Language + +All repository content (code, comments, docs, commit messages) is in **English**. +The only exception is the author's personal development diary, which is kept +locally in `private-notes/` and excluded from the repository. + +## Release roadmap + +The project ships in small, tagged increments. Each version is merged to `main` +before the next begins: + +- **0.0.1** — Core generator, character sets, validation, tests, docs. (done) +- **0.1.0** — First public release on RubyGems.org via Trusted Publishing. +- **0.2.0** — Kiro skills for gem authors (feature TDD, version bump, release). +- **0.3.0** — Project `.kiro/` folder with full steering and conventions. +- **0.4.0** — Kiro hooks (run specs on save, changelog reminders, etc.). +- **0.5.0** — A Ruby MCP server exposing password generation as a tool. +- **0.6.0** — A fluent/builder API layered on top of the keyword-argument API. diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md new file mode 100644 index 0000000..9c97b19 --- /dev/null +++ b/.kiro/steering/structure.md @@ -0,0 +1,67 @@ +# Structure + +## Directory layout + +``` +password_forge/ +├── lib/ +│ ├── password_forge.rb # Entry point: requires all components +│ └── password_forge/ +│ ├── version.rb # VERSION constant +│ ├── errors.rb # Error, NoCharsetSelectedError +│ ├── charset.rb # Charset value object (character sets + build) +│ ├── validation.rb # Validation module (selection + length) +│ └── generator.rb # Generator class + PasswordForge.generate +├── spec/ +│ ├── spec_helper.rb +│ ├── password_forge_spec.rb # Top-level (version) spec +│ └── password_forge/ +│ ├── charset_spec.rb +│ ├── validation_spec.rb +│ └── generator_spec.rb +├── sig/ # RBS type signatures +├── .github/workflows/ +│ ├── main.yml # CI: RSpec matrix + RuboCop +│ └── release.yml # Trusted Publishing on v* tags +├── .kiro/ # Kiro steering / skills / hooks +├── private-notes/ # Local-only, git-ignored (dev diary) +├── password_forge.gemspec +├── Gemfile +├── Rakefile +├── README.md +├── CHANGELOG.md +└── LICENSE.txt +``` + +## Architecture + +The internal design mirrors the original C# separation of concerns while +staying idiomatic Ruby: + +- **`PasswordForge::Charset`** — a module acting as a value object. Holds the + frozen `UPPER`, `LOWER`, `NUMERIC` and `SPECIAL` constants and a `build` + method that returns the pool of characters for the selected sets. +- **`PasswordForge::Validation`** — a module with `validate_charset_selection` + (raises when no set is selected) and `validate_length` (positive integer). +- **`PasswordForge::NoCharsetSelectedError`** — the equivalent of the C# + `InvalidCharSetException`; subclass of `PasswordForge::Error`. +- **`PasswordForge::Generator`** — the public class. The constructor validates + input and builds the pool; `#generate` returns a `SecureRandom`-backed + password. +- **`PasswordForge.generate(**options)`** — a top-level convenience wrapper. + +## Public API conventions + +- The `Generator` constructor uses keyword arguments: + `upper_case:`, `lower_case:`, `numeric_case:`, `special_case:` (all `true`), + and `length:` (default 16). This matches the C# constructor parameter names. +- A fluent/builder API is planned for v0.6.0 and must be **additive**: the + keyword-argument API keeps working unchanged. + +## Naming + +- **Gem name:** `password_forge` (underscore) — what users `gem install`. +- **GitHub repo:** `password-forge-ruby-gem` (hyphens) — more descriptive for + discovery. The two intentionally differ. +- Namespace all code under the `PasswordForge` module; one file per component + under `lib/password_forge/`. diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md new file mode 100644 index 0000000..62760b3 --- /dev/null +++ b/.kiro/steering/tech.md @@ -0,0 +1,68 @@ +# Tech + +## Stack + +- **Language:** Ruby, `required_ruby_version >= 3.0.0`. +- **Test framework:** RSpec. +- **Linter:** RuboCop (config in `.rubocop.yml`, `TargetRubyVersion: 3.0`). +- **Randomness:** `SecureRandom` (standard library, no runtime dependencies). +- **Type signatures:** RBS stubs under `sig/`. + +The gem has **no runtime dependencies**. + +## Common commands + +Run from the `password_forge/` directory: + +```bash +bundle install # install development dependencies +bundle exec rake # default task: RSpec + RuboCop +bundle exec rspec # run the test suite only +bundle exec rubocop # run the linter only +bundle exec rubocop -A # auto-correct safe offences +gem build password_forge.gemspec # build the gem locally +bin/console # interactive prompt with the gem loaded +``` + +## Development workflow + +- **TDD.** Write the spec first, watch it fail, implement to green, then + refactor. Keep the suite and RuboCop green before every commit. +- **Branches.** Do feature work on a `feature/vX.Y.Z-*` branch and merge to + `main` per milestone. Never push directly to `main` for feature work. +- **Versioning.** Semantic Versioning. Bump `lib/password_forge/version.rb`, + update `CHANGELOG.md` (Keep a Changelog format), then tag `vX.Y.Z`. + +## Release process (Trusted Publishing) + +Publishing is automated via OIDC — no API tokens are stored. + +1. A one-time setup on RubyGems.org registers a trusted publisher: + - RubyGem name: `password_forge` + - Repository owner: `devandreacarratta` + - Repository name: `password-forge-ruby-gem` + - Workflow filename: `release.yml` + - Environment: `release` + For the very first publish (gem not yet on RubyGems), a **pending** trusted + publisher is registered from the RubyGems profile before the gem exists. +2. `.github/workflows/release.yml` triggers on `v*` tags, uses + `rubygems/release-gem@v1` with `contents: write` + `id-token: write` and the + `release` GitHub environment. +3. Releasing = bump version, update CHANGELOG, merge to `main`, push the tag. + +## Key decisions (recorded so they are not re-litigated) + +- **Gem email removed.** `spec.email` is intentionally omitted from the gemspec + (it is optional and would be public). The RubyGems account email is separate + and private. +- **`Gemfile.lock` is not committed.** Committing it pinned `BUNDLED WITH 4.x`, + which broke CI on Ruby < 3.2 (Bundler 4 requires Ruby 3.2+). Gems resolve the + lockfile per environment, so it is git-ignored. +- **Gem name vs repo name differ on purpose** (`password_forge` vs + `password-forge-ruby-gem`). +- **MFA required for pushes** via `rubygems_mfa_required = "true"` in the + gemspec. +- **CI splits test and lint:** RSpec runs across Ruby 3.0–3.4; RuboCop runs once + on 3.4 to avoid version-specific style noise. +- **Personal diary** lives in `private-notes/` (git-ignored), everything else is + English and tracked. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0cc0691 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.1] - 2026-09-05 + +### Added + +- `PasswordForge::Generator` with keyword-argument constructor + (`upper_case:`, `lower_case:`, `numeric_case:`, `special_case:`, `length:`) + and a `SecureRandom`-backed `#generate`. +- `PasswordForge::Charset` value object exposing the `UPPER`, `LOWER`, + `NUMERIC` and `SPECIAL` character sets and a `build` method. +- `PasswordForge::Validation` for charset selection and length checks. +- `PasswordForge::NoCharsetSelectedError`, raised when no character set is + selected. +- `PasswordForge.generate` convenience wrapper. +- RSpec test suite, RuboCop configuration and README. + +[Unreleased]: https://github.com/devandreacarratta/password-forge-ruby-gem/compare/v0.0.1...HEAD +[0.0.1]: https://github.com/devandreacarratta/password-forge-ruby-gem/releases/tag/v0.0.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ecf6a3 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# PasswordForge + +[![Ruby](https://github.com/devandreacarratta/password-forge-ruby-gem/actions/workflows/main.yml/badge.svg)](https://github.com/devandreacarratta/password-forge-ruby-gem/actions/workflows/main.yml) + +A configurable password generator for Ruby with selectable character sets. + +`PasswordForge` builds passwords from four character categories — uppercase, +lowercase, numeric and special — that you switch on or off independently. All +categories are enabled by default, and a clear error is raised if you disable +every one of them. Randomness is provided by Ruby's `SecureRandom`. + +This gem is also a showcase for building and shipping a Ruby gem the **Kiro +way**: it ships with Kiro skills, project steering, hooks and an MCP server (see +the [Roadmap](#roadmap)). + +## Installation + +Install the gem and add it to the application's Gemfile by executing: + +```bash +bundle add password_forge +``` + +If Bundler is not being used to manage dependencies, install the gem by +executing: + +```bash +gem install password_forge +``` + +## Usage + +### Quick start + +```ruby +require "password_forge" + +# All character sets enabled, default length of 16 +PasswordForge.generate +# => "aB3$xY7!qR2@kL9%" +``` + +### Using the generator directly + +The constructor takes four boolean flags (all `true` by default) plus a +`length`. This mirrors the design of the original C# / NuGet package: + +```ruby +generator = PasswordForge::Generator.new( + upper_case: true, # include A-Z + lower_case: true, # include a-z + numeric_case: true, # include 0-9 + special_case: true, # include special characters + length: 16 +) + +generator.generate # => "aB3$xY7!qR2@kL9%" +``` + +### Examples + +```ruby +# A 20-character password using every character set +PasswordForge::Generator.new(length: 20).generate + +# A 4-digit numeric PIN +PasswordForge::Generator.new( + upper_case: false, lower_case: false, numeric_case: true, special_case: false, length: 4 +).generate +# => "8391" + +# Letters only (no digits, no symbols) +PasswordForge::Generator.new( + numeric_case: false, special_case: false, length: 24 +).generate +``` + +### Error handling + +Disabling every character set raises `PasswordForge::NoCharsetSelectedError`: + +```ruby +PasswordForge::Generator.new( + upper_case: false, lower_case: false, numeric_case: false, special_case: false +) +# => raises PasswordForge::NoCharsetSelectedError +``` + +A non-positive or non-integer `length` raises `ArgumentError`. + +## Character sets + +| Flag | Characters | +| -------------- | ------------------------ | +| `upper_case` | `A`–`Z` | +| `lower_case` | `a`–`z` | +| `numeric_case` | `0`–`9` | +| `special_case` | `` !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ `` | + +## Roadmap + +`PasswordForge` is developed in incremental, tagged releases: + +- **0.0.1** — Core generator, character sets, validation, tests, docs. +- **0.1.0** — First public release on RubyGems.org via Trusted Publishing. +- **0.2.0** — Kiro skills for gem authors (feature TDD, version bump, release). +- **0.3.0** — Project `.kiro/` folder with steering and conventions. +- **0.4.0** — Kiro hooks (run specs on save, changelog reminders, and more). +- **0.5.0** — A Ruby MCP server exposing password generation as a tool. +- **0.6.0** — A fluent/builder API on top of the keyword-argument API. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then run +`bundle exec rake` to run the tests and the linter. You can also run +`bin/console` for an interactive prompt to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. + +## Contributing + +Bug reports and pull requests are welcome on GitHub at +. + +## License + +The gem is available as open source under the terms of the +[MIT License](https://opensource.org/licenses/MIT). diff --git a/lib/password_forge.rb b/lib/password_forge.rb index 3f1afcf..8392fe7 100644 --- a/lib/password_forge.rb +++ b/lib/password_forge.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true require_relative "password_forge/version" +require_relative "password_forge/errors" +require_relative "password_forge/charset" +require_relative "password_forge/validation" +require_relative "password_forge/generator" # PasswordForge generates random passwords from selectable character sets. module PasswordForge - # Base error class for all PasswordForge-specific errors. - class Error < StandardError; end - # Implementation is added in the next version. end diff --git a/lib/password_forge/charset.rb b/lib/password_forge/charset.rb new file mode 100644 index 0000000..56640ad --- /dev/null +++ b/lib/password_forge/charset.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module PasswordForge + # Value object holding the available character sets and building the pool + # of characters from the selected sets. + # + # The four sets mirror the character categories of the original C# design: + # uppercase (A-Z), lowercase (a-z), numeric (0-9) and special (punctuation). + module Charset + # Uppercase letters, from 'A' to 'Z'. + UPPER = ("A".."Z").to_a.freeze + + # Lowercase letters, from 'a' to 'z'. + LOWER = ("a".."z").to_a.freeze + + # Numeric digits, from '0' to '9'. + NUMERIC = ("0".."9").to_a.freeze + + # Special (punctuation) characters. + SPECIAL = %w[! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \\ ] ^ _ ` { | } ~].freeze + + # Builds the pool of characters from the selected sets. + # + # @param upper [Boolean] include uppercase letters (A-Z) + # @param lower [Boolean] include lowercase letters (a-z) + # @param numeric [Boolean] include numeric digits (0-9) + # @param special [Boolean] include special characters + # @return [Array] the combined pool of unique characters + def self.build(upper:, lower:, numeric:, special:) + pool = [] + pool.concat(UPPER) if upper + pool.concat(LOWER) if lower + pool.concat(NUMERIC) if numeric + pool.concat(SPECIAL) if special + pool + end + end +end diff --git a/lib/password_forge/errors.rb b/lib/password_forge/errors.rb new file mode 100644 index 0000000..57db4d7 --- /dev/null +++ b/lib/password_forge/errors.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PasswordForge + # Base error class for all PasswordForge-specific errors. + class Error < StandardError; end + + # Raised when a generator is configured with no character set selected, + # mirroring the +InvalidCharSetException+ of the original C# design. + class NoCharsetSelectedError < Error + # Default message shown when no character set is selected. + DEFAULT_MESSAGE = "At least one character set must be selected " \ + "(upper_case, lower_case, numeric_case or special_case)." + + def initialize(message = DEFAULT_MESSAGE) + super + end + end +end diff --git a/lib/password_forge/generator.rb b/lib/password_forge/generator.rb new file mode 100644 index 0000000..35b26f0 --- /dev/null +++ b/lib/password_forge/generator.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "securerandom" + +module PasswordForge + # Generates random passwords from selectable character sets. + # + # The constructor mirrors the original C# +PasswordGenerator+ design: four + # boolean flags select the character sets, all enabled by default, and a + # {NoCharsetSelectedError} is raised when none is selected. + # + # @example Generate a password with all character sets (default) + # PasswordForge::Generator.new.generate # => "aB3$xY7!qR2@kL9%" + # + # @example Generate a numeric-only PIN + # PasswordForge::Generator.new( + # upper_case: false, lower_case: false, numeric_case: true, special_case: false, length: 4 + # ).generate # => "8391" + class Generator + # Default password length used when none is specified. + DEFAULT_LENGTH = 16 + + # @return [Integer] the configured password length + attr_reader :length + + # @param upper_case [Boolean] include uppercase letters (A-Z) + # @param lower_case [Boolean] include lowercase letters (a-z) + # @param numeric_case [Boolean] include numeric digits (0-9) + # @param special_case [Boolean] include special characters + # @param length [Integer] the length of the generated password + # @raise [NoCharsetSelectedError] if no character set is selected + # @raise [ArgumentError] if length is not a positive integer + def initialize(upper_case: true, lower_case: true, numeric_case: true, special_case: true, + length: DEFAULT_LENGTH) + Validation.validate_charset_selection( + upper: upper_case, lower: lower_case, numeric: numeric_case, special: special_case + ) + Validation.validate_length(length) + + @length = length + @pool = Charset.build( + upper: upper_case, lower: lower_case, numeric: numeric_case, special: special_case + ) + end + + # Generates a random password of the configured length. + # + # Uses {SecureRandom} for cryptographically secure randomness. + # + # @return [String] the generated password + def generate + Array.new(@length) { @pool[SecureRandom.random_number(@pool.length)] }.join + end + end + + # Convenience wrapper around {Generator#generate}. + # + # @see Generator#initialize + # @return [String] the generated password + def self.generate(**options) + Generator.new(**options).generate + end +end diff --git a/lib/password_forge/validation.rb b/lib/password_forge/validation.rb new file mode 100644 index 0000000..3eb211b --- /dev/null +++ b/lib/password_forge/validation.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PasswordForge + # Validates the configuration of a password generator. + # + # Mirrors the validation responsibilities of the original C# design: + # rejecting a configuration with no character set selected and rejecting + # an invalid password length. + module Validation + # Ensures that at least one character set is selected. + # + # @param upper [Boolean] uppercase flag + # @param lower [Boolean] lowercase flag + # @param numeric [Boolean] numeric flag + # @param special [Boolean] special flag + # @raise [NoCharsetSelectedError] if every flag is false + # @return [void] + def self.validate_charset_selection(upper:, lower:, numeric:, special:) + return if upper || lower || numeric || special + + raise NoCharsetSelectedError + end + + # Ensures that the requested password length is a positive integer. + # + # @param length [Integer] the requested password length + # @raise [ArgumentError] if length is not a positive integer + # @return [void] + def self.validate_length(length) + raise ArgumentError, "length must be an Integer, got #{length.class}" unless length.is_a?(Integer) + raise ArgumentError, "length must be a positive integer, got #{length}" unless length.positive? + end + end +end diff --git a/lib/password_forge/version.rb b/lib/password_forge/version.rb index 6ab2aea..76c4aa4 100644 --- a/lib/password_forge/version.rb +++ b/lib/password_forge/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PasswordForge - VERSION = "0.0.0" + VERSION = "0.0.1" end diff --git a/spec/password_forge/charset_spec.rb b/spec/password_forge/charset_spec.rb new file mode 100644 index 0000000..bec1ef4 --- /dev/null +++ b/spec/password_forge/charset_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.describe PasswordForge::Charset do + describe "constants" do + it "defines the uppercase set as A-Z" do + expect(described_class::UPPER).to eq(("A".."Z").to_a) + end + + it "defines the lowercase set as a-z" do + expect(described_class::LOWER).to eq(("a".."z").to_a) + end + + it "defines the numeric set as 0-9" do + expect(described_class::NUMERIC).to eq(("0".."9").to_a) + end + + it "defines a non-empty special set" do + expect(described_class::SPECIAL).to be_an(Array) + expect(described_class::SPECIAL).not_to be_empty + end + + it "freezes the character set constants" do + expect(described_class::UPPER).to be_frozen + expect(described_class::LOWER).to be_frozen + expect(described_class::NUMERIC).to be_frozen + expect(described_class::SPECIAL).to be_frozen + end + end + + describe ".build" do + it "returns the union of all sets when every flag is true" do + pool = described_class.build(upper: true, lower: true, numeric: true, special: true) + expected = described_class::UPPER + described_class::LOWER + + described_class::NUMERIC + described_class::SPECIAL + expect(pool).to match_array(expected) + end + + it "includes only the selected sets" do + pool = described_class.build(upper: true, lower: false, numeric: true, special: false) + expect(pool).to match_array(described_class::UPPER + described_class::NUMERIC) + end + + it "excludes characters from unselected sets" do + pool = described_class.build(upper: false, lower: true, numeric: false, special: false) + expect(pool).to match_array(described_class::LOWER) + expect(pool).not_to include(*described_class::UPPER) + expect(pool).not_to include(*described_class::NUMERIC) + end + + it "returns an empty array when no set is selected" do + pool = described_class.build(upper: false, lower: false, numeric: false, special: false) + expect(pool).to eq([]) + end + + it "returns a pool with no duplicate characters" do + pool = described_class.build(upper: true, lower: true, numeric: true, special: true) + expect(pool.uniq).to eq(pool) + end + end +end diff --git a/spec/password_forge/generator_spec.rb b/spec/password_forge/generator_spec.rb new file mode 100644 index 0000000..801f010 --- /dev/null +++ b/spec/password_forge/generator_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +RSpec.describe PasswordForge::Generator do + describe "#initialize" do + it "defaults all character sets to true and length to 16" do + generator = described_class.new + password = generator.generate + expect(password.length).to eq(16) + end + + it "raises NoCharsetSelectedError when every set is disabled" do + expect do + described_class.new( + upper_case: false, lower_case: false, numeric_case: false, special_case: false + ) + end.to raise_error(PasswordForge::NoCharsetSelectedError) + end + + it "raises ArgumentError for a non-positive length" do + expect { described_class.new(length: 0) }.to raise_error(ArgumentError) + end + end + + describe "#generate" do + it "returns a password of the requested length" do + expect(described_class.new(length: 20).generate.length).to eq(20) + end + + it "returns a String" do + expect(described_class.new.generate).to be_a(String) + end + + it "returns only digits when only the numeric set is enabled" do + generator = described_class.new( + upper_case: false, lower_case: false, numeric_case: true, special_case: false, length: 8 + ) + password = generator.generate + expect(password).to match(/\A\d{8}\z/) + end + + it "returns only uppercase letters when only the uppercase set is enabled" do + generator = described_class.new( + upper_case: true, lower_case: false, numeric_case: false, special_case: false, length: 12 + ) + expect(generator.generate).to match(/\A[A-Z]{12}\z/) + end + + it "excludes characters from unselected sets" do + generator = described_class.new( + upper_case: true, lower_case: true, numeric_case: false, special_case: false, length: 50 + ) + expect(generator.generate).to match(/\A[A-Za-z]{50}\z/) + end + + it "produces different passwords across calls (probabilistically)" do + generator = described_class.new(length: 32) + expect(generator.generate).not_to eq(generator.generate) + end + + it "draws every character from the selected pool" do + generator = described_class.new(length: 200) + pool = PasswordForge::Charset.build(upper: true, lower: true, numeric: true, special: true) + expect(generator.generate.chars.all? { |c| pool.include?(c) }).to be(true) + end + end + + describe "PasswordForge.generate" do + it "is a convenience wrapper returning a password" do + expect(PasswordForge.generate(length: 10)).to be_a(String) + expect(PasswordForge.generate(length: 10).length).to eq(10) + end + + it "forwards charset flags" do + password = PasswordForge.generate( + upper_case: false, lower_case: false, numeric_case: true, special_case: false, length: 6 + ) + expect(password).to match(/\A\d{6}\z/) + end + end +end diff --git a/spec/password_forge/validation_spec.rb b/spec/password_forge/validation_spec.rb new file mode 100644 index 0000000..88b32ee --- /dev/null +++ b/spec/password_forge/validation_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.describe PasswordForge::Validation do + describe ".validate_charset_selection" do + it "does not raise when at least one flag is true" do + expect do + described_class.validate_charset_selection( + upper: false, lower: false, numeric: false, special: true + ) + end.not_to raise_error + end + + it "does not raise when all flags are true" do + expect do + described_class.validate_charset_selection( + upper: true, lower: true, numeric: true, special: true + ) + end.not_to raise_error + end + + it "raises NoCharsetSelectedError when every flag is false" do + expect do + described_class.validate_charset_selection( + upper: false, lower: false, numeric: false, special: false + ) + end.to raise_error(PasswordForge::NoCharsetSelectedError) + end + + it "raises with a descriptive message" do + expect do + described_class.validate_charset_selection( + upper: false, lower: false, numeric: false, special: false + ) + end.to raise_error(PasswordForge::NoCharsetSelectedError, /at least one character set/i) + end + end + + describe ".validate_length" do + it "does not raise for a positive length" do + expect { described_class.validate_length(16) }.not_to raise_error + end + + it "raises ArgumentError for zero length" do + expect { described_class.validate_length(0) }.to raise_error(ArgumentError) + end + + it "raises ArgumentError for a negative length" do + expect { described_class.validate_length(-1) }.to raise_error(ArgumentError) + end + + it "raises ArgumentError for a non-integer length" do + expect { described_class.validate_length("16") }.to raise_error(ArgumentError) + end + end +end