Caddy module to require at-proto authentication and restrict routes to DIDs
1package test
2
3import (
4 "net/http"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/caddyserver/caddy/v2"
10 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
11 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
12 _ "github.com/caddyserver/caddy/v2/modules/standard"
13
14 _ "tangled.org/vvill.dev/caddy-atproto-auth" // Register modules
15)
16
17func TestCaddyIntegration(t *testing.T) {
18 // 1. Setup Caddyfile
19 input := `
20 {
21 admin off
22 atproto {
23 storage_path :memory:
24 cookie_secret "my-secret-key-must-be-very-long-and-secure"
25 }
26 }
27
28 :8080 {
29 route /auth/* {
30 uri strip_prefix /auth
31 atproto_portal {
32 domain localhost:8080
33 name "Test Portal"
34 }
35 }
36
37 route /protected/* {
38 atproto_gate {
39 allow @test.bsky.social
40 portal_url http://localhost:8080/auth
41 }
42 respond "Authorized Content"
43 }
44 }
45 `
46
47 // 2. Parse and Load Config
48 adapter := caddyfile.Adapter{
49 ServerType: httpcaddyfile.ServerType{},
50 }
51
52 jsonConfig, warnings, err := adapter.Adapt([]byte(input), nil)
53 if err != nil {
54 t.Fatalf("Failed to adapt config: %v", err)
55 }
56 if len(warnings) > 0 {
57 t.Logf("Warnings: %v", warnings)
58 }
59
60 err = caddy.Load(jsonConfig, true)
61 if err != nil {
62 t.Fatalf("Failed to load caddy: %v", err)
63 }
64 defer caddy.Stop()
65
66 // 3. Helper to simulate requests
67 // Since Caddy is running its own listeners, we can just make HTTP requests to it.
68 // But in a test environment, binding ports might be flaky.
69 // Ideally we'd invoke the handler directly, but getting the handler chain from Caddy is complex.
70 // We'll rely on the real HTTP server since we used :8080.
71
72 // Wait a moment for server start
73 time.Sleep(100 * time.Millisecond)
74
75 baseURL := "http://localhost:8080"
76 client := &http.Client{
77 CheckRedirect: func(req *http.Request, via []*http.Request) error {
78 return http.ErrUseLastResponse // Don't follow redirects
79 },
80 }
81
82 t.Run("Unauthorized Access Redirects to Portal", func(t *testing.T) {
83 resp, err := client.Get(baseURL + "/protected/resource")
84 if err != nil {
85 t.Fatalf("Request failed: %v", err)
86 }
87 defer resp.Body.Close()
88
89 if resp.StatusCode != http.StatusFound {
90 t.Errorf("Expected status 302, got %d", resp.StatusCode)
91 }
92
93 location := resp.Header.Get("Location")
94 if !strings.Contains(location, "/auth/login") {
95 t.Errorf("Expected redirect to portal login, got %s", location)
96 }
97 })
98
99 t.Run("Portal Serves Login Page", func(t *testing.T) {
100 resp, err := client.Get(baseURL + "/auth/login")
101 if err != nil {
102 t.Fatalf("Request failed: %v", err)
103 }
104 defer resp.Body.Close()
105
106 if resp.StatusCode != http.StatusOK {
107 t.Errorf("Expected status 200, got %d", resp.StatusCode)
108 }
109
110 // Verify content type (HTML)
111 ct := resp.Header.Get("Content-Type")
112 if !strings.Contains(ct, "text/html") {
113 t.Errorf("Expected HTML content, got %s", ct)
114 }
115 })
116
117 t.Run("Portal Serves Metadata", func(t *testing.T) {
118 resp, err := client.Get(baseURL + "/auth/.well-known/oauth-client-metadata.json")
119 if err != nil {
120 t.Fatalf("Request failed: %v", err)
121 }
122 defer resp.Body.Close()
123
124 if resp.StatusCode != http.StatusOK {
125 t.Errorf("Expected status 200, got %d", resp.StatusCode)
126 }
127
128 // Verify content type (JSON)
129 ct := resp.Header.Get("Content-Type")
130 if !strings.Contains(ct, "application/json") {
131 t.Errorf("Expected JSON content, got %s", ct)
132 }
133 })
134}