Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ certs/ca/private/ca_*.pem
certs/
Dockerfile.dev.v2
CLAUDE.md
AGENT.md
.claude/
.memory-bank
own/
15 changes: 13 additions & 2 deletions app/controllers/api/v1/registrant/domains_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ def index
status: :bad_request) && return
end

domains = current_user_domains
company_codes = listing_company_codes
domains, total = Domain.listing_for_registrant(
current_registrant_user,
company_codes,
tech_param: params[:tech],
limit_total: LIMIT_DOMAIN_TOTAL
)

serialized_domains = domains.limit(limit).offset(offset).map do |item|
serializer = Serializers::RegistrantApi::Domain.new(item, simplify: simple)
serializer.to_json
end

render json: { total: current_user_domains_total_count, count: domains.count,
render json: { total: total, count: domains.count,
domains: serialized_domains }
end

Expand All @@ -52,6 +59,10 @@ def set_tech_flag
params.merge!(tech: 'true')
end

def listing_company_codes
ListingCompanyCodesResolver.new(current_registrant_user).call
end

def current_user_domains_total_count
current_registrant_user.domains.count
rescue CompanyRegister::NotAvailableError
Expand Down
4 changes: 4 additions & 0 deletions app/models/contact.rb
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,10 @@ def unlinked
contacts.id)')
end

def org_contacts_by_codes(company_codes, country_alpha2)
where(ident_type: ORG, ident: company_codes, ident_country_code: country_alpha2)
end

def registrant_user_company_contacts(registrant_user)
ident = registrant_user.companies.collect(&:registration_number)

Expand Down
40 changes: 40 additions & 0 deletions app/models/domain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,46 @@ def nameserver_required?
Setting.nameserver_required
end

def listing_user_domains(registrant_user, company_codes, admin: false)
direct = if admin
registrant_user_direct_admin_registrant_domains(registrant_user)
else
registrant_user_direct_domains(registrant_user)
end

return direct if company_codes.blank?

org_contacts = Contact.org_contacts_by_codes(company_codes, registrant_user.country.alpha2)
company_registrant = registrant_user_company_registrant(org_contacts)
company_by_contact = registrant_user_domains_company(org_contacts, except_tech: admin)

from(
"(#{direct.to_sql} UNION " \
"#{company_registrant.to_sql} UNION " \
"#{company_by_contact.to_sql}) AS domains"
)
end

def listing_user_domains_count(registrant_user, company_codes)
listing_user_domains(registrant_user, company_codes, admin: false).count
end

# Resolves the `admin` flag for the listing query based on the `tech` param.
# For `tech=init` we need the total count to decide if we should hide tech
# contacts (admin-only view) — that same total is returned to the caller
# to avoid a second count query.
def listing_for_registrant(registrant_user, company_codes, tech_param:, limit_total:)
if tech_param == 'init'
total = listing_user_domains_count(registrant_user, company_codes)
admin = total >= limit_total
[listing_user_domains(registrant_user, company_codes, admin: admin), total]
else
admin = tech_param != 'true'
relation = listing_user_domains(registrant_user, company_codes, admin: admin)
[relation, listing_user_domains_count(registrant_user, company_codes)]
end
end

def registrant_user_admin_registrant_domains(registrant_user)
companies = Contact.registrant_user_company_contacts(registrant_user)
from(
Expand Down
115 changes: 115 additions & 0 deletions app/services/listing_company_codes_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
require 'net/http'
require 'openssl'
require 'socket'
require 'httpi'

class ListingCompanyCodesResolver
CACHE_VERSION = 'v1'
STALE_GRACE_PERIOD = 24.hours
FALLBACK_TTL = 1.day

# Network-level errors that indicate the business registry is unreachable.
# The company_register gem does NOT wrap these into CompanyRegister::NotAvailableError —
# depending on the HTTP adapter in use (HTTPI or Net::HTTP), raw errors bubble up.
NETWORK_ERRORS = [
Net::OpenTimeout,
Net::ReadTimeout,
Errno::ECONNREFUSED,
Errno::EHOSTUNREACH,
Errno::ENETUNREACH,
Errno::ETIMEDOUT,
SocketError,
OpenSSL::SSL::SSLError,
HTTPI::Error,
].freeze

attr_reader :user, :cache, :company_register, :logger

def initialize(user, cache: Rails.cache, company_register: CompanyRegister::Client.new, logger: Rails.logger)
@user = user
@cache = cache
@company_register = company_register
@logger = logger
end

def call
return [] if user.ident.include?('-')

fetch_with_stale_fallback
end

private

# Primary caching is handled by CompanyRegister::Client internally
# (cache_store.fetch with cache_period TTL). This resolver only adds
# a stale fallback layer: on every successful lookup (cached or live),
# we persist codes to a stale key with an extended TTL. On error,
# we fall back to that stale key.
def fetch_with_stale_fallback
codes = resolve_company_codes
write_stale_cache(codes)
log(:info, 'live_success')
codes
rescue CompanyRegister::NotAvailableError
stale_fallback
rescue CompanyRegister::SOAPFaultError
log(:error, 'soap_fault_direct_only')
[]
rescue *NETWORK_ERRORS => e
log(:warn, 'network_error', error_class: e.class.name, error_message: e.message)
stale_fallback
end

def resolve_company_codes
results = company_register.representation_rights(
citizen_personal_code: user.ident,
citizen_country_code: user.country.alpha3
)
results.map(&:registration_number).compact.uniq
end

def stale_fallback
cached_stale = cache.read(stale_key)
if cached_stale
log(:warn, 'stale_fallback')
cached_stale
else
log(:error, 'empty_after_error')
[]
end
end

# Stale-cache write is an observability concern, not the read path.
# Any cache backend failure (Redis down, memcache timeout, etc.) must not
# break the live lookup that already succeeded — we just lose the fallback
# for the next outage window and log it.
def write_stale_cache(codes)
cache.write(stale_key, codes, expires_in: cache_ttl + STALE_GRACE_PERIOD)
rescue StandardError => e
log(:warn, 'cache_write_failed', error: e.message)
end

def cache_ttl
period = CompanyRegister.configuration.cache_period
if period.nil? || period <= 0
log(:warn, 'invalid_cache_period')
FALLBACK_TTL
else
period
end
end

def stale_key
"registrant/listing_company_codes_stale/#{CACHE_VERSION}/#{user.id}"
end

def log(level, outcome, extra = {})
payload = { user_id: user.id, outcome: outcome }.merge(extra).to_json

case level
when :info then logger.info(payload)
when :warn then logger.warn(payload)
when :error then logger.error(payload)
end
end
end
144 changes: 144 additions & 0 deletions db/seeds_mock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,150 @@ def generate_random_string(length = 8)
end
end

# ============================================================
# Stale Fallback Testing Data (registrant_center#165)
# ============================================================
#
# TARA test user: MARY ANN O'CONNEZ-SUSLIK
# Personal code: 60001019906
# Phone (Mobile-ID): +37200000766
#
# Testing procedure:
# 1. Run this seed: rails runner db/seeds_mock.rb
# 2. Log into registrant center via TARA with 60001019906
# 3. Open /api/v1/registrant/domains?tech=init
# 4. You should see BOTH direct domains (maryann-*.ee) AND company-linked domains (acme-*.ee, globex-*.ee)
# 5. To simulate business registry outage: change company_register_password to invalid value and restart
# 6. Clear primary gem cache: Rails.cache.delete(gem_cache_key) — see below
# 7. Reload domains listing — company-linked domains should still appear (stale fallback)
# 8. After stale TTL expires (cache_period + 24h), company-linked domains will disappear
#
puts "=========================================="
puts "Setting up Stale Fallback Test Data..."
puts "=========================================="

# Use existing registrar (MOCKREG1) for test domains
test_registrar = Registrar.find_by!(code: "MOCKREG1")

# RegistrantUser matching TARA test ID
registrant_user = RegistrantUser.find_or_create_by!(registrant_ident: 'EE-60001019906') do |u|
u.username = "MARY ANN O'CONNEZ-SUSLIK"
end
registrant_user.update!(username: "MARY ANN O'CONNEZ-SUSLIK")
puts " RegistrantUser: #{registrant_user.username} (#{registrant_user.registrant_ident})"

# PRIV contact matching user's ident — for direct domains
priv_contact = Registrant.find_or_create_by!(code: "TARA:PRIV:60001019906") do |c|
c.name = "MARY ANN O'CONNEZ-SUSLIK"
c.email = "maryann+mock@example.com"
c.phone = '+372.50000766'
c.registrar = test_registrar
c.country_code = 'EE'
c.city = 'Tallinn'
c.street = 'Test St 1'
c.zip = '10111'
c.ident_country_code = 'EE'
c.ident_type = 'priv'
c.ident = '60001019906'
end
puts " PRIV contact: #{priv_contact.name} (ident=#{priv_contact.ident})"

# ORG contacts — for company-linked domains
# These registration numbers come from CompanyRegister demo endpoint
# for test user 60001019906 (test_mode: true in config/application.yml)
mock_companies = [
{ reg_number: '12345678', name: 'Andmesilla DEMO OÜ', code: 'TARA:ORG:ANDMESILLA' },
{ reg_number: '10112390', name: 'SAUTEC AS', code: 'TARA:ORG:SAUTEC' },
{ reg_number: '10001880', name: 'OÜ Spider Autogrupp', code: 'TARA:ORG:SPIDER' },
]

org_contacts = mock_companies.map do |company|
contact = Registrant.find_or_create_by!(code: company[:code]) do |c|
c.name = company[:name]
c.email = "#{company[:name].parameterize}+mock@example.com"
c.phone = generate_phone
c.registrar = test_registrar
c.country_code = 'EE'
c.city = 'Tallinn'
c.street = 'Business St 1'
c.zip = '10111'
c.ident_country_code = 'EE'
c.ident_type = 'org'
c.ident = company[:reg_number]
end
puts " ORG contact: #{contact.name} (ident=#{contact.ident})"
contact
end

# Direct domains — owned by PRIV contact (always visible)
2.times do |i|
domain_name = "maryann-#{i+1}.#{zone_origin}"
domain = Domain.find_or_create_by!(name: domain_name) do |d|
d.registrar = test_registrar
d.registrant = priv_contact
d.period = 1
d.period_unit = 'y'
d.valid_to = 1.year.from_now
d.admin_contacts << priv_contact
d.tech_contacts << priv_contact
2.times do |j|
d.nameservers.build(
hostname: "ns#{j+1}.#{domain_name}",
ipv4: ["192.0.2.#{100+i*10+j}"],
ipv6: ["2001:db8::#{100+i*10+j}"]
)
end
end
puts " Direct domain: #{domain.name} (registrant=#{priv_contact.name})" if domain.persisted?
end

# Company-linked domains — owned by ORG contacts (visible via company representation)
org_contacts.each_with_index do |org_contact, ci|
2.times do |i|
prefix = org_contact.name.split.first.downcase
domain_name = "#{prefix}-#{i+1}.#{zone_origin}"
domain = Domain.find_or_create_by!(name: domain_name) do |d|
d.registrar = test_registrar
d.registrant = org_contact
d.period = 1
d.period_unit = 'y'
d.valid_to = 1.year.from_now
d.admin_contacts << org_contact
d.tech_contacts << org_contact
2.times do |j|
d.nameservers.build(
hostname: "ns#{j+1}.#{domain_name}",
ipv4: ["192.0.2.#{200+ci*20+i*10+j}"],
ipv6: ["2001:db8::#{200+ci*20+i*10+j}"]
)
end
end
puts " Company domain: #{domain.name} (registrant=#{org_contact.name})" if domain.persisted?
end
end

puts ""
puts " Test data summary:"
puts " User: EE-60001019906 (MARY ANN O'CONNEZ-SUSLIK)"
puts " Direct domains: maryann-1.ee, maryann-2.ee"
puts " Company domains (from demo business registry):"
puts " andmesilla-1.ee, andmesilla-2.ee (Andmesilla DEMO OÜ, reg=12345678)"
puts " sautec-1.ee, sautec-2.ee (SAUTEC AS, reg=10112390)"
puts " spider-1.ee, spider-2.ee (OÜ Spider Autogrupp, reg=10001880)"
puts ""
puts " Prerequisites:"
puts " company_register_test_mode: 'true' in config/application.yml"
puts ""
puts " Testing procedure:"
puts " 1. Run this seed: rails runner db/seeds_mock.rb"
puts " 2. Restart the app"
puts " 3. Log in via TARA with 60001019906 (+37200000766 for Mobile-ID)"
puts " 4. You should see 8 domains (2 direct + 6 company-linked)"
puts " 5. To simulate outage: set company_register_password to 'invalid' and restart"
puts " 6. Company-linked domains should still appear via stale cache"
puts " 7. After stale TTL expires (cache_period + 24h) they will disappear"
puts "=========================================="

# Custom User requested by the user
puts "Processing Custom Registrar: REG1..."
custom_registrar = Registrar.find_or_create_by!(code: "REG1") do |r|
Expand Down
Loading
Loading