Skip to content

Commit 3dc8657

Browse files
committed
Initial custom domain model and tests
1 parent ad770c2 commit 3dc8657

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

rails/app/models/custom_domain.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
class CustomDomain < ApplicationRecord
2+
belongs_to :site
3+
4+
validates :site_id, uniqueness: true
5+
validates :domain, presence: true, uniqueness: true, format: { with: /\A[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\z/ }
6+
validate :not_reserved_domain
7+
8+
enum :status, { pending_verification: 0, verified: 1, active: 2, failed: 3 }, default: :pending_verification
9+
enum :ssl_status, { pending: 0, issued: 1, expired: 2, failed: 3 }, prefix: :ssl, default: :pending
10+
11+
scope :expiring_soon, -> { where(ssl_status: :issued).where('ssl_expires_at < ?', 30.days.from_now) }
12+
scope :fully_active, -> { where(status: :active, ssl_status: :issued) }
13+
14+
before_validation :normalize_domain
15+
before_create :generate_verification_token
16+
17+
# Check if domain is fully active (verified and has valid SSL)
18+
def fully_active?
19+
active? && ssl_issued?
20+
end
21+
22+
# DNS TXT record name for verification
23+
def verification_record_name
24+
"_tiddlyhost-verification.#{domain}"
25+
end
26+
27+
# DNS TXT record value for verification
28+
def verification_record_value
29+
verification_token
30+
end
31+
32+
# Human-readable DNS verification instructions
33+
def dns_verification_instructions
34+
<<~INSTRUCTIONS
35+
To verify ownership of your domain, add this DNS TXT record:
36+
37+
Name: #{verification_record_name}
38+
Type: TXT
39+
Value: #{verification_record_value}
40+
41+
After adding the record, click "Verify Domain" to continue.
42+
DNS changes can take a few minutes to propagate.
43+
INSTRUCTIONS
44+
end
45+
46+
private
47+
48+
def normalize_domain
49+
self.domain = domain.downcase.strip if domain.present?
50+
end
51+
52+
def not_reserved_domain
53+
return if domain.blank?
54+
55+
reserved_domains = ['tiddlyhost.com', 'tiddlyspot.com', 'localhost']
56+
reserved_patterns = [/\.tiddlyhost\.com\z/, /\.tiddlyspot\.com\z/]
57+
58+
if reserved_domains.include?(domain) || reserved_patterns.any? { |pattern| domain.match?(pattern) }
59+
errors.add(:domain, "cannot use reserved domains or their subdomains")
60+
end
61+
end
62+
63+
def generate_verification_token
64+
self.verification_token = SecureRandom.hex(16)
65+
end
66+
end

rails/app/models/site.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class Site < ApplicationRecord
1111
# could return nil even if the id field is present.)
1212
belongs_to :cloned_from, optional: true, class_name: :Site
1313

14+
has_one :custom_domain, dependent: :destroy
15+
1416
# The timestamps can be a few milliseconds apart, so that's why we need the interval
1517
# Todo: blob_created_at would be a more useful timestamp to use here than updated_at.
1618
scope :never_updated, -> { where("AGE(sites.updated_at, sites.created_at) <= INTERVAL '0.5 SECOND'") }
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
require 'test_helper'
2+
3+
class CustomDomainTest < ActiveSupport::TestCase
4+
setup do
5+
@site = sites(:mysite)
6+
@custom_domain = CustomDomain.new(
7+
site: @site,
8+
domain: 'example.com'
9+
)
10+
end
11+
12+
test 'valid custom domain' do
13+
assert @custom_domain.valid?
14+
end
15+
16+
test 'domain is required' do
17+
@custom_domain.domain = nil
18+
refute @custom_domain.valid?
19+
assert_includes @custom_domain.errors[:domain], "can't be blank"
20+
end
21+
22+
test 'domain must be unique' do
23+
@custom_domain.save!
24+
another_site = Site.create!(name: 'another-site', user: @site.user, empty: @site.empty)
25+
duplicate = CustomDomain.new(site: another_site, domain: 'example.com')
26+
refute duplicate.valid?
27+
assert_includes duplicate.errors[:domain], "has already been taken"
28+
end
29+
30+
test 'domain normalization downcases domain' do
31+
@custom_domain.domain = 'EXAMPLE.COM'
32+
@custom_domain.valid?
33+
assert_equal 'example.com', @custom_domain.domain
34+
end
35+
36+
test 'domain normalization strips whitespace' do
37+
@custom_domain.domain = ' example.com '
38+
@custom_domain.valid?
39+
assert_equal 'example.com', @custom_domain.domain
40+
end
41+
42+
test 'domain format validation' do
43+
# Valid domains
44+
[
45+
'example.com',
46+
'sub.example.com',
47+
'my-site.example.com',
48+
'a.b.c.example.com',
49+
'123.example.com',
50+
].each do |valid_domain|
51+
@custom_domain.domain = valid_domain
52+
assert @custom_domain.valid?, "#{valid_domain} should be valid"
53+
end
54+
55+
# Invalid domains
56+
[
57+
'example', # no TLD
58+
'-example.com', # starts with dash
59+
'example-.com', # ends with dash
60+
'exam ple.com', # contains space
61+
'example..com', # double dot
62+
'.example.com', # starts with dot
63+
'example.com.', # ends with dot
64+
].each do |invalid_domain|
65+
@custom_domain.domain = invalid_domain
66+
refute @custom_domain.valid?, "#{invalid_domain} should be invalid"
67+
end
68+
end
69+
70+
test 'blocks exact reserved domains' do
71+
['tiddlyhost.com', 'tiddlyspot.com', 'localhost'].each do |reserved|
72+
@custom_domain.domain = reserved
73+
refute @custom_domain.valid?
74+
assert_includes @custom_domain.errors[:domain], "cannot use reserved domains or their subdomains"
75+
end
76+
end
77+
78+
test 'blocks subdomains of reserved domains' do
79+
[
80+
'www.tiddlyhost.com',
81+
'admin.tiddlyhost.com',
82+
'api.tiddlyhost.com',
83+
'foo.tiddlyspot.com',
84+
'bar.baz.tiddlyhost.com',
85+
].each do |reserved_subdomain|
86+
@custom_domain.domain = reserved_subdomain
87+
refute @custom_domain.valid?, "#{reserved_subdomain} should be blocked"
88+
assert_includes @custom_domain.errors[:domain], "cannot use reserved domains or their subdomains"
89+
end
90+
end
91+
92+
test 'allows domains containing reserved words but not as subdomain' do
93+
# These should be allowed since they're not subdomains
94+
[
95+
'tiddlyhostfan.com',
96+
'mytiddlyhost.org',
97+
].each do |allowed_domain|
98+
@custom_domain.domain = allowed_domain
99+
assert @custom_domain.valid?, "#{allowed_domain} should be allowed"
100+
end
101+
end
102+
103+
test 'generates verification token on create' do
104+
assert_nil @custom_domain.verification_token
105+
@custom_domain.save!
106+
assert_not_nil @custom_domain.verification_token
107+
assert_equal 32, @custom_domain.verification_token.length # hex(16) = 32 chars
108+
end
109+
110+
test 'default status is pending_verification' do
111+
@custom_domain.save!
112+
assert @custom_domain.pending_verification?
113+
assert_equal 0, @custom_domain.status_before_type_cast
114+
end
115+
116+
test 'default ssl_status is pending' do
117+
@custom_domain.save!
118+
assert @custom_domain.ssl_pending?
119+
assert_equal 0, @custom_domain.ssl_status_before_type_cast
120+
end
121+
122+
test 'status enum states' do
123+
@custom_domain.save!
124+
125+
@custom_domain.pending_verification!
126+
assert @custom_domain.pending_verification?
127+
128+
@custom_domain.verified!
129+
assert @custom_domain.verified?
130+
131+
@custom_domain.active!
132+
assert @custom_domain.active?
133+
134+
@custom_domain.failed!
135+
assert @custom_domain.failed?
136+
end
137+
138+
test 'ssl_status enum states with prefix' do
139+
@custom_domain.save!
140+
141+
@custom_domain.ssl_pending!
142+
assert @custom_domain.ssl_pending?
143+
144+
@custom_domain.ssl_issued!
145+
assert @custom_domain.ssl_issued?
146+
147+
@custom_domain.ssl_expired!
148+
assert @custom_domain.ssl_expired?
149+
150+
@custom_domain.ssl_failed!
151+
assert @custom_domain.ssl_failed?
152+
end
153+
154+
test 'fully_active? requires both active status and issued SSL' do
155+
@custom_domain.save!
156+
157+
# Not active yet
158+
@custom_domain.pending_verification!
159+
@custom_domain.ssl_pending!
160+
refute @custom_domain.fully_active?
161+
162+
# Active but no SSL
163+
@custom_domain.active!
164+
@custom_domain.ssl_pending!
165+
refute @custom_domain.fully_active?
166+
167+
# SSL issued but not active
168+
@custom_domain.verified!
169+
@custom_domain.ssl_issued!
170+
refute @custom_domain.fully_active?
171+
172+
# Both active and SSL issued
173+
@custom_domain.active!
174+
@custom_domain.ssl_issued!
175+
assert @custom_domain.fully_active?
176+
end
177+
178+
test 'fully_active scope' do
179+
@custom_domain.save!
180+
181+
# Create additional sites for testing (one site = one custom domain)
182+
pending_site = Site.create!(name: 'pending-site', user: @site.user, empty: @site.empty)
183+
active_site = Site.create!(name: 'active-site', user: @site.user, empty: @site.empty)
184+
185+
pending_domain = CustomDomain.create!(site: pending_site, domain: 'pending.com')
186+
active_domain = CustomDomain.create!(
187+
site: active_site,
188+
domain: 'active.com',
189+
status: :active,
190+
ssl_status: :issued
191+
)
192+
193+
fully_active_domains = CustomDomain.fully_active
194+
assert_includes fully_active_domains, active_domain
195+
refute_includes fully_active_domains, pending_domain
196+
refute_includes fully_active_domains, @custom_domain
197+
end
198+
199+
test 'expiring_soon scope only includes issued certificates' do
200+
@custom_domain.save!
201+
202+
# Create additional sites for testing (one site = one custom domain)
203+
expiring_site = Site.create!(name: 'expiring-site', user: @site.user, empty: @site.empty)
204+
safe_site = Site.create!(name: 'safe-site', user: @site.user, empty: @site.empty)
205+
pending_site = Site.create!(name: 'pending-cert-site', user: @site.user, empty: @site.empty)
206+
207+
# Create domains with different SSL states
208+
expired_soon = CustomDomain.create!(
209+
site: expiring_site,
210+
domain: 'expires-soon.com',
211+
ssl_status: :issued,
212+
ssl_expires_at: 15.days.from_now
213+
)
214+
215+
not_expiring = CustomDomain.create!(
216+
site: safe_site,
217+
domain: 'not-expiring.com',
218+
ssl_status: :issued,
219+
ssl_expires_at: 60.days.from_now
220+
)
221+
222+
pending_cert = CustomDomain.create!(
223+
site: pending_site,
224+
domain: 'pending-cert.com',
225+
ssl_status: :pending,
226+
ssl_expires_at: 15.days.from_now
227+
)
228+
229+
expiring = CustomDomain.expiring_soon
230+
assert_includes expiring, expired_soon
231+
refute_includes expiring, not_expiring
232+
refute_includes expiring, pending_cert
233+
end
234+
235+
test 'verification_record_name' do
236+
@custom_domain.domain = 'example.com'
237+
assert_equal '_tiddlyhost-verification.example.com', @custom_domain.verification_record_name
238+
end
239+
240+
test 'verification_record_value returns token' do
241+
@custom_domain.save!
242+
assert_equal @custom_domain.verification_token, @custom_domain.verification_record_value
243+
end
244+
245+
test 'dns_verification_instructions contains key information' do
246+
@custom_domain.save!
247+
instructions = @custom_domain.dns_verification_instructions
248+
249+
assert_includes instructions, @custom_domain.verification_record_name
250+
assert_includes instructions, @custom_domain.verification_token
251+
assert_includes instructions, 'TXT'
252+
assert_includes instructions, 'Verify Domain'
253+
end
254+
255+
test 'belongs to site' do
256+
assert_equal @site, @custom_domain.site
257+
end
258+
259+
test 'site can only have one custom domain' do
260+
@custom_domain.save!
261+
262+
second_domain = CustomDomain.new(site: @site, domain: 'another.com')
263+
refute second_domain.valid?
264+
assert_includes second_domain.errors[:site_id], "has already been taken"
265+
end
266+
267+
test 'custom domain is destroyed when site is destroyed' do
268+
@custom_domain.save!
269+
assert_difference 'CustomDomain.count', -1 do
270+
@site.destroy
271+
end
272+
end
273+
end

0 commit comments

Comments
 (0)