Back to Repositories

Validating SMTP Authentication Workflows in Postal

This test suite validates SMTP authentication mechanisms in the Postal server, focusing on PLAIN, LOGIN, and CRAM-MD5 authentication methods. The tests ensure secure credential handling and proper authentication state management.

Test Coverage Overview

The test suite provides comprehensive coverage of SMTP authentication flows:
  • PLAIN authentication with single-line and multi-line credential handling
  • LOGIN authentication with username/password sequence validation
  • CRAM-MD5 authentication with challenge-response mechanism
  • Error handling for invalid credentials and malformed inputs

Implementation Analysis

The testing approach utilizes RSpec’s behavior-driven development patterns with nested describe/context blocks for clear test organization. The implementation leverages factory-created credentials and Base64 encoding/decoding for SMTP authentication strings.

Key patterns include state management validation, credential verification, and proper response code checking.

Technical Details

Testing tools and configuration:
  • RSpec for test framework
  • Factory patterns for credential generation
  • OpenSSL for CRAM-MD5 digest creation
  • Base64 encoding for credential handling
  • Mock SMTP client implementation

Best Practices Demonstrated

The test suite exemplifies several testing best practices:
  • Isolated test contexts for different authentication methods
  • Comprehensive error case coverage
  • Clear setup and teardown procedures
  • Consistent state verification
  • Proper separation of concerns in test organization

postalserver/postal

spec/lib/smtp_server/client/auth_spec.rb

            
# frozen_string_literal: true

require "rails_helper"

module SMTPServer

  describe Client do
    let(:ip_address) { "1.2.3.4" }
    subject(:client) { described_class.new(ip_address) }

    before do
      client.handle("HELO test.example.com")
    end

    describe "AUTH PLAIN" do
      context "when no credentials are provided on the initial data" do
        it "returns a 334" do
          expect(client.handle("AUTH PLAIN")).to eq("334")
        end

        it "accepts the username and password from the next input" do
          client.handle("AUTH PLAIN")
          credential = create(:credential, type: "SMTP")
          expect(client.handle(credential.to_smtp_plain)).to match(/235 Granted for/)
        end
      end

      context "when valid credentials are provided on one line" do
        it "authenticates and returns a response" do
          credential = create(:credential, type: "SMTP")
          expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for/)
          expect(client.credential).to eq credential
        end
      end

      context "when invalid credentials are provided" do
        it "returns an error and resets the state" do
          base64 = Base64.encode64("user\0pass")
          expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Invalid credential")
          expect(client.state).to eq :welcomed
        end
      end

      context "when username or password is missing" do
        it "returns an error and resets the state" do
          base64 = Base64.encode64("pass")
          expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Authenticated failed - protocol error")
          expect(client.state).to eq :welcomed
        end
      end
    end

    describe "AUTH LOGIN" do
      context "when no username is provided on the first line" do
        it "requests the username" do
          expect(client.handle("AUTH LOGIN")).to eq("334 VXNlcm5hbWU6")
        end

        it "requests a password after a username" do
          client.handle("AUTH LOGIN")
          expect(client.handle("xx")).to eq("334 UGFzc3dvcmQ6")
        end

        it "authenticates and returns a response if the password is correct" do
          client.handle("AUTH LOGIN")
          client.handle("xx")
          credential = create(:credential, type: "SMTP")
          password = Base64.encode64(credential.key)
          expect(client.handle(password)).to match(/235 Granted for/)
        end

        it "returns an error when an invalid credential is provided" do
          client.handle("AUTH LOGIN")
          client.handle("xx")
          password = Base64.encode64("xx")
          expect(client.handle(password)).to eq("535 Invalid credential")
        end
      end

      context "when a username is provided on the first line" do
        it "requests a password" do
          username = Base64.encode64("xx")
          expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6")
        end

        it "authenticates and returns a response" do
          credential = create(:credential, type: "SMTP")
          username = Base64.encode64("xx")
          password = Base64.encode64(credential.key)
          expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6")
          expect(client.handle(password)).to match(/235 Granted for/)
          expect(client.credential).to eq credential
        end

        it "returns an error and resets the state" do
          username = Base64.encode64("xx")
          password = Base64.encode64("xx")
          expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6")
          expect(client.handle(password)).to eq("535 Invalid credential")
          expect(client.state).to eq :welcomed
        end
      end
    end

    describe "AUTH CRAM-MD5" do
      context "when valid credentials are provided" do
        it "authenticates and returns a response" do
          credential = create(:credential, type: "SMTP")
          result = client.handle("AUTH CRAM-MD5")
          expect(result).to match(/\A334 [A-Za-z0-9=]+\z/)
          challenge = Base64.decode64(result.split[1])
          password = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("md5"), credential.key, challenge)
          base64 = Base64.encode64("#{credential.server.organization.permalink}/#{credential.server.permalink} #{password}")
          expect(client.handle(base64)).to match(/235 Granted for/)
          expect(client.credential).to eq credential
        end
      end

      context "when no org/server matches the provided username" do
        it "returns an error" do
          client.handle("AUTH CRAM-MD5")
          base64 = Base64.encode64("org/server password")
          expect(client.handle(base64)).to eq "535 Denied"
        end
      end

      context "when invalid credentials are provided" do
        it "returns an error and resets the state" do
          server = create(:server)
          base64 = Base64.encode64("#{server.organization.permalink}/#{server.permalink} invalid-password")
          client.handle("AUTH CRAM-MD5")
          expect(client.handle(base64)).to eq("535 Denied")
        end
      end
    end
  end

end