~bigbes/lethe

ref: 7cffe38a672c0f5fc825235f496c72bebc9ee2b3 lethe/internal/server/auth/devstub_test.go -rw-r--r-- 4.7 KiB
7cffe38a — Eugene Blikh web: add display preference modules a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
package auth_test

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// freeLoopbackPort picks an available TCP port on 127.0.0.1 by briefly
// binding and then releasing it. Port-race is acceptable at test scale.
func freeLoopbackPort(t *testing.T) int {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("freeLoopbackPort: Listen: %v", err)
	}
	port := ln.Addr().(*net.TCPAddr).Port
	ln.Close()
	return port
}

// newTestLogger builds an initialised observability.Logger for use in tests.
func newTestLogger(t *testing.T) *observability.Logger {
	t.Helper()
	logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
	if err := logger.Init(context.Background()); err != nil {
		t.Fatalf("logger.Init: %v", err)
	}
	return logger
}

// TestOIDCDevStub_InitStartsListener_TokenVerifies verifies that:
//  1. OIDCDevStub.Init starts an HTTP listener on the configured bind address.
//  2. The listener satisfies OIDC discovery so OIDCVerifier.Init succeeds.
//  3. GET /dev/token?sub=alice returns a JWT that OIDCVerifier.Verify accepts
//     and resolves to username "alice".
func TestOIDCDevStub_InitStartsListener_TokenVerifies(t *testing.T) {
	port := freeLoopbackPort(t)
	bind := fmt.Sprintf("127.0.0.1:%d", port)
	issuer := "http://" + bind

	cfg := config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC: config.OIDCConfig{
			Enabled:       true,
			Issuer:        issuer,
			Audience:      "lethe",
			UsernameClaim: "preferred_username",
			DevStub: config.OIDCDevStubConfig{
				Enabled:  true,
				Bind:     bind,
				TokenTTL: time.Hour,
			},
		},
	}

	stub := &auth.OIDCDevStub{
		Cfg: cfg,
		Log: newTestLogger(t),
	}

	ctx := context.Background()

	if err := stub.Init(ctx); err != nil {
		t.Fatalf("OIDCDevStub.Init: %v", err)
	}
	t.Cleanup(func() {
		shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		if err := stub.Destroy(shutCtx); err != nil {
			t.Logf("OIDCDevStub.Destroy: %v", err)
		}
	})

	// Prove the listener answers OIDC discovery by constructing a real verifier.
	verifier := &auth.OIDCVerifier{
		Cfg: config.AuthConfig{
			OIDC: config.OIDCConfig{
				Enabled:       true,
				Issuer:        issuer,
				Audience:      "lethe",
				UsernameClaim: "preferred_username",
			},
		},
	}
	if err := verifier.Init(ctx); err != nil {
		t.Fatalf("OIDCVerifier.Init: %v", err)
	}

	// Fetch a token from the /dev/token endpoint.
	resp, err := http.Get(fmt.Sprintf("http://%s/dev/token?sub=alice", bind))
	if err != nil {
		t.Fatalf("GET /dev/token: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("/dev/token status = %d; body = %s", resp.StatusCode, body)
	}

	var tokenResp struct {
		Token string `json:"token"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
		t.Fatalf("decode token response: %v", err)
	}
	if tokenResp.Token == "" {
		t.Fatal("token is empty")
	}

	// Verify the token resolves to alice.
	user, err := verifier.Verify(ctx, tokenResp.Token)
	if err != nil {
		t.Fatalf("verifier.Verify: %v", err)
	}
	if user != "alice" {
		t.Errorf("Verify returned user = %q; want alice", user)
	}
}

// TestOIDCDevStub_DisabledIsNoop verifies that when DevStub.Enabled is false
// Init returns nil without starting any listener.
func TestOIDCDevStub_DisabledIsNoop(t *testing.T) {
	port := freeLoopbackPort(t)
	bind := fmt.Sprintf("127.0.0.1:%d", port)
	issuer := "http://" + bind

	cfg := config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC: config.OIDCConfig{
			Enabled:       true,
			Issuer:        issuer,
			Audience:      "lethe",
			UsernameClaim: "preferred_username",
			DevStub: config.OIDCDevStubConfig{
				Enabled: false,
				Bind:    bind,
			},
		},
	}

	stub := &auth.OIDCDevStub{
		Cfg: cfg,
		Log: newTestLogger(t),
	}

	ctx := context.Background()
	if err := stub.Init(ctx); err != nil {
		t.Fatalf("OIDCDevStub.Init (disabled): %v", err)
	}

	// Give the goroutine scheduler a moment, then assert nothing is listening.
	time.Sleep(50 * time.Millisecond)

	conn, err := net.DialTimeout("tcp", bind, 100*time.Millisecond)
	if err == nil {
		conn.Close()
		t.Errorf("expected no listener on %s when DevStub.Enabled=false, but dialled successfully", bind)
	}

	// Destroy on a no-op stub must also be safe.
	shutCtx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	if err := stub.Destroy(shutCtx); err != nil {
		t.Errorf("OIDCDevStub.Destroy (disabled): %v", err)
	}
}