initial commit
Format / gofmt (push) Failing after 27s
CI / Build (push) Successful in 51s
CI / Go Tests (push) Failing after 21s

This commit is contained in:
2026-06-06 07:54:44 -05:00
commit 1cc94f2c99
68 changed files with 14615 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+453
View File
@@ -0,0 +1,453 @@
package main
import (
"bytes"
"net/http"
"testing"
"helix-proxy/internal/store"
)
func apiPutRaw(t *testing.T, url, rawBody, token string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte(rawBody)))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
return resp
}
func TestAPI_UnauthorizedEndpoints(t *testing.T) {
env := newAPITestServer(t)
paths := []string{
"/api/proxy-hosts",
"/api/access-lists",
"/api/streams",
"/api/certificates",
"/api/redirection-hosts",
"/api/dead-hosts",
"/api/settings",
"/api/audit-logs",
"/api/dashboard",
"/api/users",
"/api/users/me",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
assertStatus(t, apiGet(t, env.BaseURL+path, ""), http.StatusUnauthorized)
})
}
}
func TestAPI_BadIDBranches(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
cases := []struct {
name string
method string
path string
want int
}{
{"proxy-host get", http.MethodGet, "/api/proxy-hosts/nope", http.StatusBadRequest},
{"proxy-host put", http.MethodPut, "/api/proxy-hosts/nope", http.StatusBadRequest},
{"proxy-host delete", http.MethodDelete, "/api/proxy-hosts/bad", http.StatusBadRequest},
{"proxy-host enable", http.MethodPost, "/api/proxy-hosts/x/enable", http.StatusBadRequest},
{"proxy-host disable", http.MethodPost, "/api/proxy-hosts/x/disable", http.StatusBadRequest},
{"access-list put", http.MethodPut, "/api/access-lists/abc", http.StatusBadRequest},
{"access-list delete", http.MethodDelete, "/api/access-lists/abc", http.StatusBadRequest},
{"stream put", http.MethodPut, "/api/streams/nope", http.StatusBadRequest},
{"stream delete", http.MethodDelete, "/api/streams/bad", http.StatusBadRequest},
{"stream enable", http.MethodPost, "/api/streams/x/enable", http.StatusBadRequest},
{"stream disable", http.MethodPost, "/api/streams/x/disable", http.StatusBadRequest},
{"cert delete", http.MethodDelete, "/api/certificates/bad", http.StatusBadRequest},
{"redir put", http.MethodPut, "/api/redirection-hosts/nope", http.StatusBadRequest},
{"redir delete", http.MethodDelete, "/api/redirection-hosts/bad", http.StatusBadRequest},
{"redir disable", http.MethodPost, "/api/redirection-hosts/x/disable", http.StatusBadRequest},
{"dead put", http.MethodPut, "/api/dead-hosts/nope", http.StatusBadRequest},
{"dead delete", http.MethodDelete, "/api/dead-hosts/bad", http.StatusBadRequest},
{"dead disable", http.MethodPost, "/api/dead-hosts/x/disable", http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var resp *http.Response
switch tc.method {
case http.MethodGet:
resp = apiGet(t, env.BaseURL+tc.path, token)
case http.MethodPut:
resp = apiPut(t, env.BaseURL+tc.path, map[string]any{"name": "x"}, token)
case http.MethodDelete:
resp = apiDelete(t, env.BaseURL+tc.path, token)
default:
resp = apiPost(t, env.BaseURL+tc.path, nil, token)
}
assertStatus(t, resp, tc.want)
})
}
}
func TestAPI_NotFoundEnableDisable(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
cases := []string{
"/api/proxy-hosts/9999/disable",
"/api/streams/9999/enable",
"/api/streams/9999/disable",
"/api/redirection-hosts/9999/enable",
"/api/dead-hosts/9999/disable",
"/api/dead-hosts/9999/enable",
}
for _, path := range cases {
t.Run(path, func(t *testing.T) {
assertStatus(t, apiPost(t, env.BaseURL+path, nil, token), http.StatusNotFound)
})
}
}
func TestAPI_MalformedJSONOnCreateAndUpdate(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
createPaths := []string{
"/api/proxy-hosts",
"/api/access-lists",
"/api/streams",
"/api/redirection-hosts",
"/api/dead-hosts",
}
for _, path := range createPaths {
t.Run("post "+path, func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+path, `{`, token), http.StatusBadRequest)
})
}
ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"malformed-ph.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
"forwardScheme": "http",
}, token)
if ph.StatusCode != http.StatusCreated {
t.Fatalf("proxy host: %d", ph.StatusCode)
}
var proxyHost store.ProxyHost
decodeJSON(t, ph, &proxyHost)
al := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{
"name": "malformed-acl",
"clients": []map[string]string{{"address": "all", "directive": "allow"}},
}, token)
if al.StatusCode != http.StatusCreated {
t.Fatalf("acl: %d", al.StatusCode)
}
var accessList store.AccessList
decodeJSON(t, al, &accessList)
strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": pickFreePort(t),
"forwardingHost": "127.0.0.1",
"forwardingPort": 9,
"tcpForwarding": true,
"enabled": false,
}, token)
if strm.StatusCode != http.StatusCreated {
t.Fatalf("stream: %d", strm.StatusCode)
}
var stream store.Stream
decodeJSON(t, strm, &stream)
redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"malformed-redir.example"},
"forwardDomainName": "target.example",
"enabled": true,
}, token)
if redir.StatusCode != http.StatusCreated {
t.Fatalf("redir: %d", redir.StatusCode)
}
var redirection store.RedirectionHost
decodeJSON(t, redir, &redirection)
dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"malformed-dead.example"},
"enabled": true,
}, token)
if dead.StatusCode != http.StatusCreated {
t.Fatalf("dead: %d", dead.StatusCode)
}
var deadHost store.DeadHost
decodeJSON(t, dead, &deadHost)
putCases := []struct {
name string
url string
}{
{"proxy-host", env.BaseURL + "/api/proxy-hosts/" + itoa(proxyHost.ID)},
{"access-list", env.BaseURL + "/api/access-lists/" + itoa(accessList.ID)},
{"stream", env.BaseURL + "/api/streams/" + itoa(stream.ID)},
{"redirection", env.BaseURL + "/api/redirection-hosts/" + itoa(redirection.ID)},
{"dead-host", env.BaseURL + "/api/dead-hosts/" + itoa(deadHost.ID)},
}
for _, tc := range putCases {
t.Run("put "+tc.name, func(t *testing.T) {
assertStatus(t, apiPutRaw(t, tc.url, `{`, token), http.StatusBadRequest)
})
}
}
func TestAPI_UpdateConflict409(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
t.Run("proxy-hosts", func(t *testing.T) {
a := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"conflict-a.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
"forwardScheme": "http",
}, token)
b := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"conflict-b.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 2,
"forwardScheme": "http",
}, token)
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
}
var hostB store.ProxyHost
decodeJSON(t, b, &hostB)
assertStatus(t, apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(hostB.ID), map[string]any{
"domainNames": []string{"conflict-a.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 2,
"forwardScheme": "http",
}, token), http.StatusConflict)
})
t.Run("redirection-hosts", func(t *testing.T) {
a := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"conflict-redir-a.example"},
"forwardDomainName": "a.example",
"enabled": true,
}, token)
b := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"conflict-redir-b.example"},
"forwardDomainName": "b.example",
"enabled": true,
}, token)
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
}
var hostB store.RedirectionHost
decodeJSON(t, b, &hostB)
assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(hostB.ID), map[string]any{
"domainNames": []string{"conflict-redir-a.example"},
"forwardDomainName": "b.example",
"enabled": true,
}, token), http.StatusConflict)
})
t.Run("dead-hosts", func(t *testing.T) {
a := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"conflict-dead-a.example"},
"enabled": true,
}, token)
b := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"conflict-dead-b.example"},
"enabled": true,
}, token)
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
}
var hostB store.DeadHost
decodeJSON(t, b, &hostB)
assertStatus(t, apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(hostB.ID), map[string]any{
"domainNames": []string{"conflict-dead-a.example"},
"enabled": true,
}, token), http.StatusConflict)
})
t.Run("streams", func(t *testing.T) {
port := pickFreePort(t)
a := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": port,
"forwardingHost": "127.0.0.1",
"forwardingPort": 9,
"tcpForwarding": true,
"enabled": false,
}, token)
otherPort := pickFreePort(t)
b := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": otherPort,
"forwardingHost": "127.0.0.1",
"forwardingPort": 10,
"tcpForwarding": true,
"enabled": false,
}, token)
if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode)
}
var streamB store.Stream
decodeJSON(t, b, &streamB)
assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/"+itoa(streamB.ID), map[string]any{
"incomingPort": port,
"forwardingHost": "127.0.0.1",
"forwardingPort": 10,
"tcpForwarding": true,
"enabled": false,
}, token), http.StatusConflict)
})
}
func TestAPI_ForbiddenEnableDisableBranches(t *testing.T) {
env := newAPITestServer(t)
adminTok := env.loginToken(t)
userTok := env.userToken(t, 2)
ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"forbid-enable-ph.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
"forwardScheme": "http",
"enabled": true,
}, adminTok)
var proxyHost store.ProxyHost
decodeJSON(t, ph, &proxyHost)
assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID)+"/enable", nil, userTok), http.StatusForbidden)
strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": pickFreePort(t),
"forwardingHost": "127.0.0.1",
"forwardingPort": 9,
"tcpForwarding": true,
"enabled": false,
}, adminTok)
var stream store.Stream
decodeJSON(t, strm, &stream)
assertStatus(t, apiPost(t, env.BaseURL+"/api/streams/"+itoa(stream.ID)+"/disable", nil, userTok), http.StatusForbidden)
redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"forbid-disable-redir.example"},
"forwardDomainName": "x.example",
"enabled": true,
}, adminTok)
var redirection store.RedirectionHost
decodeJSON(t, redir, &redirection)
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID)+"/disable", nil, userTok), http.StatusForbidden)
dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"forbid-enable-dead.example"},
"enabled": true,
}, adminTok)
var deadHost store.DeadHost
decodeJSON(t, dead, &deadHost)
assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID)+"/enable", nil, userTok), http.StatusForbidden)
}
func TestAPI_LoginLegacyPlainPassword(t *testing.T) {
env := newAPITestServer(t)
u, ok := env.Store.GetUserByEmail("admin@example.com")
if !ok {
t.Fatal("admin user missing")
}
u.Password = "legacyplain"
if err := env.Store.UpdateUser(u); err != nil {
t.Fatalf("update user: %v", err)
}
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "legacyplain"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("legacy login: %d %s", resp.StatusCode, readBody(resp))
}
var loginOut struct {
Token string `json:"token"`
}
decodeJSON(t, resp, &loginOut)
if loginOut.Token == "" {
t.Fatal("empty token for legacy login")
}
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
"currentPassword": "legacyplain",
"newPassword": "newsecure123",
}, loginOut.Token), http.StatusOK)
}
func TestAPI_PasswordChangeEmptyNew(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
"currentPassword": "password",
"newPassword": "",
}, token), http.StatusBadRequest)
}
func TestAPI_ListEndpointsOK(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
lists := []string{
"/api/access-lists",
"/api/streams",
"/api/certificates",
"/api/redirection-hosts",
"/api/dead-hosts",
"/api/settings",
}
for _, path := range lists {
t.Run(path, func(t *testing.T) {
assertStatus(t, apiGet(t, env.BaseURL+path, token), http.StatusOK)
})
}
}
func TestAPI_DeleteNotFoundBranches(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
cases := []string{
"/api/redirection-hosts/9999",
"/api/dead-hosts/9999",
"/api/streams/9999",
}
for _, path := range cases {
t.Run(path, func(t *testing.T) {
assertStatus(t, apiDelete(t, env.BaseURL+path, token), http.StatusNotFound)
})
}
}
func TestAPI_PutNotFoundBranches(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
cases := []struct {
path string
body map[string]any
}{
{"/api/streams/9999", map[string]any{
"incomingPort": 1, "forwardingHost": "127.0.0.1", "forwardingPort": 1, "tcpForwarding": true,
}},
{"/api/redirection-hosts/9999", map[string]any{"domainNames": []string{"x.example"}}},
{"/api/dead-hosts/9999", map[string]any{"domainNames": []string{"x.example"}}},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
assertStatus(t, apiPut(t, env.BaseURL+tc.path, tc.body, token), http.StatusNotFound)
})
}
}
+345
View File
@@ -0,0 +1,345 @@
package main
import (
"bytes"
"net/http"
"strings"
"testing"
"helix-proxy/internal/store"
)
func assertStatus(t *testing.T, resp *http.Response, want int) {
t.Helper()
if resp.StatusCode != want {
t.Errorf("status: got %d want %d body=%s", resp.StatusCode, want, readBody(resp))
return
}
_ = resp.Body.Close()
}
func apiPostRaw(t *testing.T, url, rawBody, token string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte(rawBody)))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
return resp
}
func TestAPI_ErrorPaths(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
t.Run("auth", func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/login", `{`, ""), http.StatusBadRequest)
assertStatus(t, apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"}, ""), http.StatusUnauthorized)
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "not-a-jwt"), http.StatusUnauthorized)
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "Bearer"), http.StatusUnauthorized)
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "Basic dXNlcjpwYXNz"), http.StatusUnauthorized)
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/users/me/password", `not-json`, token), http.StatusBadRequest)
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
"currentPassword": "password",
"newPassword": "password",
}, token), http.StatusBadRequest)
assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
"currentPassword": "wrong",
"newPassword": "newpass123",
}, token), http.StatusUnauthorized)
})
t.Run("proxy-hosts", func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/proxy-hosts", `{`, token), http.StatusBadRequest)
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts/bad-id", token), http.StatusBadRequest)
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts/9999", token), http.StatusNotFound)
assertStatus(t, apiPut(t, env.BaseURL+"/api/proxy-hosts/9999", map[string]any{
"domainNames": []string{"x.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
}, token), http.StatusNotFound)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/proxy-hosts/9999", token), http.StatusNotFound)
assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/9999/enable", nil, token), http.StatusNotFound)
})
t.Run("access-lists", func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/access-lists", `{`, token), http.StatusBadRequest)
assertStatus(t, apiGet(t, env.BaseURL+"/api/access-lists/nope", token), http.StatusBadRequest)
assertStatus(t, apiGet(t, env.BaseURL+"/api/access-lists/9999", token), http.StatusNotFound)
assertStatus(t, apiPut(t, env.BaseURL+"/api/access-lists/9999", map[string]any{"name": "x"}, token), http.StatusNotFound)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/access-lists/9999", token), http.StatusNotFound)
})
t.Run("streams", func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/streams", `{`, token), http.StatusBadRequest)
assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/bad", map[string]any{}, token), http.StatusBadRequest)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/streams/9999", token), http.StatusNotFound)
first := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": 29100,
"forwardingHost": "127.0.0.1",
"forwardingPort": 9,
"tcpForwarding": true,
"enabled": false,
}, token)
if first.StatusCode != http.StatusCreated {
t.Fatalf("stream create: %d %s", first.StatusCode, readBody(first))
}
dup := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": 29100,
"forwardingHost": "127.0.0.1",
"forwardingPort": 10,
"tcpForwarding": true,
"enabled": false,
}, token)
assertStatus(t, dup, http.StatusConflict)
})
t.Run("certificates", func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/certificates", `{`, token), http.StatusBadRequest)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/certificates/9999", token), http.StatusNotFound)
assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/9999/renew", nil, token), http.StatusNotFound)
assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/bad/renew", nil, token), http.StatusBadRequest)
})
t.Run("redirection-hosts", func(t *testing.T) {
assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/9999", map[string]any{
"domainNames": []string{"x.example"},
}, token), http.StatusNotFound)
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/9999/disable", nil, token), http.StatusNotFound)
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/bad/enable", nil, token), http.StatusBadRequest)
first := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"dup-redir.example"},
"forwardDomainName": "target.example",
"forwardScheme": "https",
"preservePath": true,
"enabled": true,
}, token)
if first.StatusCode != http.StatusCreated {
t.Fatalf("redir create: %d %s", first.StatusCode, readBody(first))
}
dup := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"dup-redir.example"},
"forwardDomainName": "other.example",
"enabled": true,
}, token)
assertStatus(t, dup, http.StatusConflict)
})
t.Run("dead-hosts", func(t *testing.T) {
assertStatus(t, apiDelete(t, env.BaseURL+"/api/dead-hosts/9999", token), http.StatusNotFound)
assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/bad/enable", nil, token), http.StatusBadRequest)
first := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"dup-dead.example"},
"enabled": true,
}, token)
if first.StatusCode != http.StatusCreated {
t.Fatalf("dead create: %d %s", first.StatusCode, readBody(first))
}
dup := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"dup-dead.example"},
"enabled": true,
}, token)
assertStatus(t, dup, http.StatusConflict)
})
t.Run("settings", func(t *testing.T) {
assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/settings", `{`, token), http.StatusBadRequest)
})
}
func TestAPI_ForbiddenNonOwner(t *testing.T) {
env := newAPITestServer(t)
adminTok := env.loginToken(t)
userTok := env.userToken(t, 2)
al := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{
"name": "owned-by-admin",
"clients": []map[string]string{{"address": "all", "directive": "allow"}},
}, adminTok)
if al.StatusCode != http.StatusCreated {
t.Fatalf("acl create: %d", al.StatusCode)
}
var created store.AccessList
decodeJSON(t, al, &created)
assertStatus(t, apiPut(t, env.BaseURL+"/api/access-lists/"+itoa(created.ID), map[string]any{
"name": "hijack",
}, userTok), http.StatusForbidden)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/access-lists/"+itoa(created.ID), userTok), http.StatusForbidden)
ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"forbidden-ph.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
"forwardScheme": "http",
}, adminTok)
if ph.StatusCode != http.StatusCreated {
t.Fatalf("proxy host: %d", ph.StatusCode)
}
var proxyHost store.ProxyHost
decodeJSON(t, ph, &proxyHost)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID), userTok), http.StatusForbidden)
assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID)+"/disable", nil, userTok), http.StatusForbidden)
strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": pickFreePort(t),
"forwardingHost": "127.0.0.1",
"forwardingPort": 9,
"tcpForwarding": true,
"enabled": false,
}, adminTok)
if strm.StatusCode != http.StatusCreated {
t.Fatalf("stream: %d %s", strm.StatusCode, readBody(strm))
}
var stream store.Stream
decodeJSON(t, strm, &stream)
assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/"+itoa(stream.ID), map[string]any{
"incomingPort": stream.IncomingPort,
"forwardingHost": "127.0.0.1",
"forwardingPort": 10,
"tcpForwarding": true,
"enabled": false,
}, userTok), http.StatusForbidden)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/streams/"+itoa(stream.ID), userTok), http.StatusForbidden)
assertStatus(t, apiPost(t, env.BaseURL+"/api/streams/"+itoa(stream.ID)+"/enable", nil, userTok), http.StatusForbidden)
redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"forbidden-redir.example"},
"forwardDomainName": "elsewhere.example",
"enabled": true,
}, adminTok)
if redir.StatusCode != http.StatusCreated {
t.Fatalf("redir: %d", redir.StatusCode)
}
var redirection store.RedirectionHost
decodeJSON(t, redir, &redirection)
assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID), map[string]any{
"domainNames": []string{"hijack-redir.example"},
}, userTok), http.StatusForbidden)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID), userTok), http.StatusForbidden)
assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID)+"/enable", nil, userTok), http.StatusForbidden)
dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"forbidden-dead.example"},
"enabled": true,
}, adminTok)
if dead.StatusCode != http.StatusCreated {
t.Fatalf("dead: %d", dead.StatusCode)
}
var deadHost store.DeadHost
decodeJSON(t, dead, &deadHost)
assertStatus(t, apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID), map[string]any{
"domainNames": []string{"hijack-dead.example"},
}, userTok), http.StatusForbidden)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID), userTok), http.StatusForbidden)
assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID)+"/disable", nil, userTok), http.StatusForbidden)
cert := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "other",
"domainNames": []string{"forbidden-cert.example"},
}, adminTok)
if cert.StatusCode != http.StatusCreated {
t.Fatalf("cert: %d", cert.StatusCode)
}
var certificate store.Certificate
decodeJSON(t, cert, &certificate)
assertStatus(t, apiDelete(t, env.BaseURL+"/api/certificates/"+itoa(certificate.ID), userTok), http.StatusForbidden)
}
func TestAPI_CertificateFailures(t *testing.T) {
t.Run("other provider create succeeds without issuance", func(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
resp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "other",
"domainNames": []string{"custom.example"},
}, token)
assertStatus(t, resp, http.StatusCreated)
})
t.Run("LE empty domains rolls back in production", func(t *testing.T) {
t.Setenv("PROXY_MODE", "")
env := newAPITestServer(t)
token := env.loginToken(t)
resp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "letsencrypt",
"domainNames": []string{},
}, token)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("empty domains: %d %s", resp.StatusCode, readBody(resp))
}
body := readBody(resp)
if !strings.Contains(body, "letsencrypt issuance failed") {
t.Errorf("body: %q", body)
}
if certs := env.Store.GetCertificates(); len(certs) != 0 {
t.Errorf("failed issue should roll back cert, got %d", len(certs))
}
})
t.Run("renew non-LE fails", func(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "other",
"domainNames": []string{"renew-fail.example"},
}, token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
}
var cert store.Certificate
decodeJSON(t, create, &cert)
renew := apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, token)
body := readBody(renew)
if renew.StatusCode != http.StatusInternalServerError {
t.Fatalf("renew: %d %s", renew.StatusCode, body)
}
if !strings.Contains(body, "renewal failed") {
t.Errorf("expected renewal failed message, got %q", body)
}
})
t.Run("renew forbidden for non-owner", func(t *testing.T) {
env := newAPITestServer(t)
adminTok := env.loginToken(t)
userTok := env.userToken(t, 2)
create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "other",
"domainNames": []string{"owned.example"},
}, adminTok)
if create.StatusCode != http.StatusCreated {
t.Fatalf("create: %d", create.StatusCode)
}
var cert store.Certificate
decodeJSON(t, create, &cert)
assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, userTok), http.StatusForbidden)
})
}
func TestAPI_UsersList(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
resp := apiGet(t, env.BaseURL+"/api/users", token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("users: %d", resp.StatusCode)
}
var users []map[string]any
decodeJSON(t, resp, &users)
if len(users) != 1 || users[0]["email"] != "admin@example.com" {
t.Errorf("users: %v", users)
}
}
+467
View File
@@ -0,0 +1,467 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"helix-proxy/internal/store"
)
func (e *apiTestEnv) loginToken(t *testing.T) string {
t.Helper()
resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": "password"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp))
}
var out struct {
Token string `json:"token"`
}
decodeJSON(t, resp, &out)
if out.Token == "" {
t.Fatal("empty login token")
}
return out.Token
}
func (e *apiTestEnv) userToken(t *testing.T, userID int) string {
t.Helper()
tok, err := e.JWT.GenerateToken(userID, "user@example.com", "User", []string{"user"})
if err != nil {
t.Fatalf("user token: %v", err)
}
return tok
}
func apiRequest(t *testing.T, method, url, token string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, r)
if err != nil {
t.Fatalf("request: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
return resp
}
func apiGet(t *testing.T, url, token string) *http.Response {
return apiRequest(t, http.MethodGet, url, token, nil)
}
func apiPost(t *testing.T, url string, body any, token string) *http.Response {
return apiRequest(t, http.MethodPost, url, token, body)
}
func apiPut(t *testing.T, url string, body any, token string) *http.Response {
return apiRequest(t, http.MethodPut, url, token, body)
}
func apiDelete(t *testing.T, url, token string) *http.Response {
return apiRequest(t, http.MethodDelete, url, token, nil)
}
func readBody(resp *http.Response) string {
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return string(b)
}
func decodeJSON(t *testing.T, resp *http.Response, dst any) {
t.Helper()
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
t.Fatalf("decode: %v body=%s", err, readBody(resp))
}
}
func TestAPI_PublicAndAuth(t *testing.T) {
env := newAPITestServer(t)
// health (no token)
resp := apiGet(t, env.BaseURL+"/api/", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("health: %d", resp.StatusCode)
}
var health map[string]any
decodeJSON(t, resp, &health)
if health["status"] != "OK" {
t.Errorf("health status: %v", health["status"])
}
// version (no RequireAuth in handler)
resp = apiGet(t, env.BaseURL+"/api/version", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("version: %d", resp.StatusCode)
}
// protected list without token -> 401
resp = apiGet(t, env.BaseURL+"/api/proxy-hosts", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("proxy-hosts no auth: %d", resp.StatusCode)
}
token := env.loginToken(t)
// me
resp = apiGet(t, env.BaseURL+"/api/users/me", token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("users/me: %d", resp.StatusCode)
}
var me map[string]any
decodeJSON(t, resp, &me)
if me["email"] != "admin@example.com" {
t.Errorf("me email: %v", me["email"])
}
}
func TestAPI_ProxyHostCRUD(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"api-proxy.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 8080,
"forwardScheme": "http",
"enabled": true,
}, token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
}
var ph store.ProxyHost
decodeJSON(t, create, &ph)
if ph.ID == 0 || ph.OwnerUserID != 1 {
t.Fatalf("created host: %+v", ph)
}
list := apiGet(t, env.BaseURL+"/api/proxy-hosts", token)
if list.StatusCode != http.StatusOK {
t.Fatalf("list: %d", list.StatusCode)
}
var hosts []store.ProxyHost
decodeJSON(t, list, &hosts)
if len(hosts) != 1 {
t.Fatalf("list len: %d", len(hosts))
}
get := apiGet(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), token)
if get.StatusCode != http.StatusOK {
t.Fatalf("get: %d", get.StatusCode)
}
up := apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), map[string]any{
"domainNames": []string{"api-proxy-updated.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 9090,
"forwardScheme": "http",
"enabled": true,
}, token)
if up.StatusCode != http.StatusOK {
t.Fatalf("update: %d %s", up.StatusCode, readBody(up))
}
dis := apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID)+"/disable", nil, token)
if dis.StatusCode != http.StatusOK {
t.Fatalf("disable: %d", dis.StatusCode)
}
en := apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID)+"/enable", nil, token)
if en.StatusCode != http.StatusOK {
t.Fatalf("enable: %d", en.StatusCode)
}
// duplicate domain -> 409
dup := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"api-proxy-updated.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
"forwardScheme": "http",
}, token)
if dup.StatusCode != http.StatusConflict {
t.Errorf("duplicate domain: %d %s", dup.StatusCode, readBody(dup))
}
// non-owner forbidden
userTok := env.userToken(t, 2)
forbid := apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), map[string]any{
"domainNames": []string{"hijack.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 1,
}, userTok)
if forbid.StatusCode != http.StatusForbidden {
t.Errorf("non-owner update: %d", forbid.StatusCode)
}
del := apiDelete(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), token)
if del.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d %s", del.StatusCode, readBody(del))
}
logs := apiGet(t, env.BaseURL+"/api/audit-logs", token)
if logs.StatusCode != http.StatusOK {
t.Fatalf("audit: %d", logs.StatusCode)
}
var audit []store.AuditLog
decodeJSON(t, logs, &audit)
if len(audit) < 3 {
t.Errorf("expected audit entries, got %d", len(audit))
}
}
func TestAPI_AccessListCRUD(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
create := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{
"name": "api-acl",
"clients": []map[string]string{
{"address": "all", "directive": "allow"},
},
"items": []map[string]string{
{"username": "u", "password": "p"},
},
}, token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
}
var al store.AccessList
decodeJSON(t, create, &al)
get := apiGet(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), token)
if get.StatusCode != http.StatusOK {
t.Fatalf("get: %d", get.StatusCode)
}
up := apiPut(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), map[string]any{
"name": "api-acl-updated",
"passAuth": true,
"clients": []map[string]string{{"address": "all", "directive": "allow"}},
"items": []map[string]string{{"username": "u2", "password": "p2"}},
}, token)
if up.StatusCode != http.StatusOK {
t.Fatalf("update: %d %s", up.StatusCode, readBody(up))
}
del := apiDelete(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), token)
if del.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", del.StatusCode)
}
}
func TestAPI_RedirectionAndDeadHosts(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
rh := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{
"domainNames": []string{"api-redir.example"},
"forwardHttpCode": 302,
"forwardScheme": "https",
"forwardDomainName": "target.example",
"preservePath": true,
"enabled": true,
}, token)
if rh.StatusCode != http.StatusCreated {
t.Fatalf("redir create: %d %s", rh.StatusCode, readBody(rh))
}
var redir store.RedirectionHost
decodeJSON(t, rh, &redir)
up := apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID), map[string]any{
"domainNames": []string{"api-redir.example"},
"forwardHttpCode": 301,
"forwardScheme": "https",
"forwardDomainName": "other.example",
"enabled": true,
}, token)
if up.StatusCode != http.StatusOK {
t.Fatalf("redir update: %d", up.StatusCode)
}
if apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID)+"/disable", nil, token).StatusCode != http.StatusOK {
t.Error("redir disable failed")
}
if apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID)+"/enable", nil, token).StatusCode != http.StatusOK {
t.Error("redir enable failed")
}
dh := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{
"domainNames": []string{"api-dead.example"},
"enabled": true,
"meta": map[string]any{"html": "<b>dead</b>"},
}, token)
if dh.StatusCode != http.StatusCreated {
t.Fatalf("dead create: %d %s", dh.StatusCode, readBody(dh))
}
var dead store.DeadHost
decodeJSON(t, dh, &dead)
if apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(dead.ID), map[string]any{
"domainNames": []string{"api-dead.example"},
"enabled": true,
}, token).StatusCode != http.StatusOK {
t.Error("dead update failed")
}
if apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(dead.ID)+"/disable", nil, token).StatusCode != http.StatusOK {
t.Error("dead disable failed")
}
list := apiGet(t, env.BaseURL+"/api/redirection-hosts", token)
if list.StatusCode != http.StatusOK {
t.Fatalf("redir list: %d", list.StatusCode)
}
}
func TestAPI_StreamCRUD(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
create := apiPost(t, env.BaseURL+"/api/streams", map[string]any{
"incomingPort": 29001,
"forwardingHost": "127.0.0.1",
"forwardingPort": 9,
"tcpForwarding": true,
"enabled": false,
}, token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
}
var strm store.Stream
decodeJSON(t, create, &strm)
up := apiPut(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), map[string]any{
"incomingPort": 29001,
"forwardingHost": "127.0.0.1",
"forwardingPort": 10,
"tcpForwarding": true,
"enabled": false,
}, token)
if up.StatusCode != http.StatusOK {
t.Fatalf("update: %d %s", up.StatusCode, readBody(up))
}
if apiPost(t, env.BaseURL+"/api/streams/"+itoa(strm.ID)+"/enable", nil, token).StatusCode != http.StatusOK {
t.Error("enable failed")
}
enabledUp := apiPut(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), map[string]any{
"incomingPort": 29001,
"forwardingHost": "127.0.0.1",
"forwardingPort": 11,
"tcpForwarding": true,
"enabled": true,
}, token)
if enabledUp.StatusCode != http.StatusOK {
t.Fatalf("update enabled same port: %d %s", enabledUp.StatusCode, readBody(enabledUp))
}
var enabledResp struct {
store.Stream
Listening bool `json:"listening"`
ListenError string `json:"listenError"`
}
decodeJSON(t, enabledUp, &enabledResp)
if !enabledResp.Listening {
t.Fatalf("expected enabled stream listening after edit, got listenError=%q", enabledResp.ListenError)
}
if apiPost(t, env.BaseURL+"/api/streams/"+itoa(strm.ID)+"/disable", nil, token).StatusCode != http.StatusOK {
t.Error("disable failed")
}
del := apiDelete(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), token)
if del.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", del.StatusCode)
}
}
func TestAPI_CertificateCRUD(t *testing.T) {
t.Setenv("PROXY_MODE", "development")
env := newAPITestServer(t)
token := env.loginToken(t)
create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "letsencrypt",
"domainNames": []string{"api-cert.example"},
}, token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %s", create.StatusCode, readBody(create))
}
var cert store.Certificate
decodeJSON(t, create, &cert)
if cert.ID == 0 {
t.Fatal("cert id missing")
}
list := apiGet(t, env.BaseURL+"/api/certificates", token)
if list.StatusCode != http.StatusOK {
t.Fatalf("list: %d", list.StatusCode)
}
renew := apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, token)
if renew.StatusCode != http.StatusOK {
t.Fatalf("renew: %d %s", renew.StatusCode, readBody(renew))
}
del := apiDelete(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID), token)
if del.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", del.StatusCode)
}
}
func TestAPI_SettingsDashboard(t *testing.T) {
env := newAPITestServer(t)
token := env.loginToken(t)
set := apiPost(t, env.BaseURL+"/api/settings", map[string]any{
"default_site": "404",
}, token)
if set.StatusCode != http.StatusOK {
t.Fatalf("settings post: %d %s", set.StatusCode, readBody(set))
}
var settings map[string]any
decodeJSON(t, set, &settings)
if settings["default_site"] != "404" {
t.Errorf("default_site: %v", settings["default_site"])
}
dash := apiGet(t, env.BaseURL+"/api/dashboard", token)
if dash.StatusCode != http.StatusOK {
t.Fatalf("dashboard: %d", dash.StatusCode)
}
var counts map[string]any
decodeJSON(t, dash, &counts)
if _, ok := counts["proxy_hosts"]; !ok {
t.Error("dashboard missing proxy_hosts")
}
}
func TestAPI_WriteStoreError(t *testing.T) {
w := httptest.NewRecorder()
writeStoreError(w, store.ErrDuplicateStreamPort)
if w.Code != http.StatusConflict {
t.Errorf("conflict: %d", w.Code)
}
w2 := httptest.NewRecorder()
writeStoreError(w2, io.ErrUnexpectedEOF)
if w2.Code != http.StatusInternalServerError {
t.Errorf("generic: %d", w2.Code)
}
}
func itoa(n int) string {
return strconv.Itoa(n)
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"context"
"crypto/tls"
"net/http"
"time"
"helix-proxy/internal/proxy"
)
// serveHTTPS listens with TLS and serves the proxy engine handler (SNI via eng.TLSConfig).
// Shuts down when ctx is cancelled.
func serveHTTPS(ctx context.Context, eng *proxy.Engine, addr string) error {
ln, err := tls.Listen("tcp", addr, eng.TLSConfig())
if err != nil {
return err
}
srv := &http.Server{
Handler: eng.Handler(),
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
_ = ln.Close()
}()
return srv.Serve(ln)
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"log/slog"
"net"
"os"
"strconv"
"syscall"
)
// applyUmaskFromEnv sets the process umask from UMASK env (octal) or defaults to 022.
func applyUmaskFromEnv() {
if u := os.Getenv("UMASK"); u != "" {
if mode, err := strconv.ParseInt(u, 8, 32); err == nil {
syscall.Umask(int(mode))
return
}
slog.Warn("invalid UMASK env (ignored)", "val", u)
}
syscall.Umask(0o022)
}
// adminListenAddr resolves the admin API/UI listen address from env.
func adminListenAddr() string {
port := "8081"
if p := os.Getenv("ADMIN_PORT"); p != "" {
port = p
}
if os.Getenv("DISABLE_IPV6") == "1" {
return "0.0.0.0:" + port
}
return net.JoinHostPort("", port)
}
// proxyHTTPSListenAddr resolves the TLS proxy listen address from env.
func proxyHTTPSListenAddr() string {
port := "18443"
if p := os.Getenv("PROXY_HTTPS_PORT"); p != "" {
port = p
}
if os.Getenv("DISABLE_IPV6") == "1" {
return "0.0.0.0:" + port
}
return net.JoinHostPort("", port)
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"testing"
)
func TestAdminListenAddr(t *testing.T) {
tests := []struct {
name string
env map[string]string
expect string
}{
{
name: "defaults ipv6",
env: map[string]string{},
expect: ":8081",
},
{
name: "custom port",
env: map[string]string{"ADMIN_PORT": "9090"},
expect: ":9090",
},
{
name: "disable ipv6",
env: map[string]string{
"DISABLE_IPV6": "1",
},
expect: "0.0.0.0:8081",
},
{
name: "custom port ipv4 only",
env: map[string]string{
"ADMIN_PORT": "3000",
"DISABLE_IPV6": "1",
},
expect: "0.0.0.0:3000",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ADMIN_PORT", "")
t.Setenv("DISABLE_IPV6", "")
for k, v := range tc.env {
t.Setenv(k, v)
}
if got := adminListenAddr(); got != tc.expect {
t.Errorf("adminListenAddr() = %q want %q", got, tc.expect)
}
})
}
}
func TestProxyHTTPSListenAddr(t *testing.T) {
tests := []struct {
name string
env map[string]string
expect string
}{
{
name: "defaults",
env: map[string]string{},
expect: ":18443",
},
{
name: "custom port",
env: map[string]string{"PROXY_HTTPS_PORT": "8443"},
expect: ":8443",
},
{
name: "ipv4 only",
env: map[string]string{
"DISABLE_IPV6": "1",
"PROXY_HTTPS_PORT": "9443",
},
expect: "0.0.0.0:9443",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PROXY_HTTPS_PORT", "")
t.Setenv("DISABLE_IPV6", "")
for k, v := range tc.env {
t.Setenv(k, v)
}
if got := proxyHTTPSListenAddr(); got != tc.expect {
t.Errorf("proxyHTTPSListenAddr() = %q want %q", got, tc.expect)
}
})
}
}
func TestApplyUmaskFromEnv(t *testing.T) {
t.Run("invalid ignored", func(t *testing.T) {
t.Setenv("UMASK", "not-octal")
applyUmaskFromEnv() // should not panic
})
t.Run("valid octal", func(t *testing.T) {
t.Setenv("UMASK", "0077")
applyUmaskFromEnv()
})
t.Run("default when unset", func(t *testing.T) {
t.Setenv("UMASK", "")
applyUmaskFromEnv()
})
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"helix-proxy/internal/auth"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
slog.Error("run failed", "err", err)
os.Exit(1)
}
}
// --- permission helpers for granular object ownership + role checks (admin can do all) ---
func isAdmin(c *auth.Claims) bool {
if c == nil {
return false
}
for _, r := range c.Roles {
if r == "admin" {
return true
}
}
return false
}
func writeStoreError(w http.ResponseWriter, err error) {
if store.IsConflictError(err) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
}
func canManage(c *auth.Claims, ownerUserID int) bool {
if c == nil {
return false
}
if isAdmin(c) {
return true
}
return c.UserID == ownerUserID
}
// applyPUIDPGID chowns the data tree (and key subdirs) if PUID/PGID envs are set
// and the process is running as root (0). This replicates the spirit of original
// entrypoint scripts for Docker non-root runs. After chown, if root, drops privs
// via Setgid+Setuid so process runs as target user (enables low ports etc only if
// needed before drop; matches summary/README "full" PUID claims).
// umask handled early in main() to affect creates.
// To support low ports (80/443) + PUID (which requires root at Listen time), set PUID_NO_DROP=1
// (chown still done as root, but no drop -- process stays root; or use high ports/caps/entrypoint drop).
func applyPUIDPGID() error {
puidStr := os.Getenv("PUID")
pgidStr := os.Getenv("PGID")
if puidStr == "" || pgidStr == "" {
return nil
}
uid, err := strconv.Atoi(puidStr)
if err != nil {
return err
}
gid, err := strconv.Atoi(pgidStr)
if err != nil {
return err
}
if os.Getuid() != 0 {
return nil // not root, skip
}
dataDir := config.DataDir()
dirs := []string{
dataDir,
config.Resolve("certs"),
config.Resolve("logs"),
config.Resolve("letsencrypt-acme-challenge"),
config.Resolve("www"),
}
for _, d := range dirs {
if err := os.Chown(d, uid, gid); err != nil {
slog.Warn("chown skipped", "dir", d, "err", err)
// continue for other dirs
}
// recursive for subfiles/dirs (use WalkDir for modern Go)
filepath.WalkDir(d, func(path string, de os.DirEntry, err error) error {
if err != nil {
return nil
}
if err := os.Chown(path, uid, gid); err != nil {
// ignore some
}
return nil
})
}
slog.Info("applied PUID/PGID chown", "uid", uid, "gid", gid, "data", dataDir)
if os.Getenv("PUID_NO_DROP") != "" {
slog.Info("PUID_NO_DROP set; skipping privilege drop after chown (remains root for low-port binds if needed)")
return nil
}
// drop privileges (setgid before setuid; only while still root)
if err := syscall.Setgid(gid); err != nil {
slog.Warn("Setgid after chown failed (still root)", "gid", gid, "err", err)
} else if err := syscall.Setuid(uid); err != nil {
slog.Warn("Setuid after chown failed", "uid", uid, "err", err)
} else {
slog.Info("dropped to PUID/PGID (non-root)", "uid", uid, "gid", gid)
}
return nil
}
// isBcryptPrefix detects bcrypt-hashed passwords for strict compare (no plain fallback).
// Supports common variants; used in login to prevent hash-str itself being usable as pw.
func isBcryptPrefix(pw string) bool {
return len(pw) > 0 && (strings.HasPrefix(pw, "$2a$") || strings.HasPrefix(pw, "$2b$") || strings.HasPrefix(pw, "$2y$"))
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"helix-proxy/internal/auth"
)
func TestIsBcryptPrefix(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", false},
{"plain", false},
{"$2a$10$abcdefghijklmnopqrstuv", true},
{"$2b$12$hash", true},
{"$2y$05$hash", true},
{"$2x$10$hash", false},
}
for _, tc := range cases {
if got := isBcryptPrefix(tc.in); got != tc.want {
t.Errorf("isBcryptPrefix(%q) = %v want %v", tc.in, got, tc.want)
}
}
}
func TestIsAdminCanManage(t *testing.T) {
adminClaims := &auth.Claims{UserID: 1, Roles: []string{"admin"}}
userClaims := &auth.Claims{UserID: 2, Roles: []string{"user"}}
nilClaims := (*auth.Claims)(nil)
if !isAdmin(adminClaims) {
t.Error("admin should be admin")
}
if isAdmin(userClaims) {
t.Error("user not admin")
}
if isAdmin(nilClaims) {
t.Error("nil not admin")
}
if !canManage(adminClaims, 99) {
t.Error("admin can manage any")
}
if !canManage(userClaims, 2) {
t.Error("owner can manage self")
}
if canManage(userClaims, 3) {
t.Error("user cannot manage other")
}
if canManage(nilClaims, 1) {
t.Error("nil cannot")
}
}
func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
t.Setenv("PROXY_MODE", "development")
env := newAPITestServer(t)
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
if resp.StatusCode != 200 {
t.Fatalf("default login failed: %d", resp.StatusCode)
}
var lr struct {
Token string `json:"token"`
MustChangePassword bool `json:"mustChangePassword"`
}
json.NewDecoder(resp.Body).Decode(&lr)
if lr.Token == "" || lr.MustChangePassword {
t.Errorf("dev mode default login should not require password change: %+v", lr)
}
}
func TestSingleAdminLogin(t *testing.T) {
t.Setenv("PROXY_MODE", "")
env := newAPITestServer(t)
st := env.Store
// initial default "password" login returns mustChangePassword
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
if resp.StatusCode != 200 {
t.Fatalf("default login failed: %d", resp.StatusCode)
}
var lr struct {
Token string `json:"token"`
User map[string]any `json:"user"`
MustChangePassword bool `json:"mustChangePassword"`
}
json.NewDecoder(resp.Body).Decode(&lr)
if lr.Token == "" || lr.User["id"] != float64(1) || !lr.MustChangePassword {
t.Errorf("default login resp bad: %+v", lr)
}
// wrong default fails
respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"})
if respBad.StatusCode != 401 {
t.Errorf("wrong default should 401: %d", respBad.StatusCode)
}
// use the returned token to change password (initial case)
token := lr.Token
authH := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token) }
chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "password", "newPassword": "newpass123"}, authH)
if chResp.StatusCode != 200 {
t.Fatalf("initial pw change failed: %d", chResp.StatusCode)
}
// after change, login with new pw succeeds, no mustChange
resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"})
if resp2.StatusCode != 200 {
t.Fatalf("new pw login failed: %d", resp2.StatusCode)
}
var lr2 struct {
Token string `json:"token"`
MustChangePassword bool `json:"mustChangePassword"`
}
json.NewDecoder(resp2.Body).Decode(&lr2)
if lr2.MustChangePassword {
t.Error("after change, mustChange should be false")
}
// confirm stored as bcrypt in the test store
uu, _ := st.GetUserByEmail("admin@example.com")
if uu.Password == "" || !isBcryptPrefix(uu.Password) {
t.Error("password not stored as bcrypt after change")
}
// changing again requires correct current
token2 := lr2.Token
authH2 := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token2) }
badCur := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "wrong", "newPassword": "another"}, authH2)
if badCur.StatusCode != 401 {
t.Errorf("bad current on change should 401: %d", badCur.StatusCode)
}
goodCh := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "newpass123", "newPassword": "finalpass"}, authH2)
if goodCh.StatusCode != 200 {
t.Errorf("subsequent change failed: %d", goodCh.StatusCode)
}
}
func postJSON(t *testing.T, url string, body any) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
return doReqClient(req)
}
func postJSONAuth(t *testing.T, url string, body any, authSetter func(*http.Request)) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
authSetter(req)
return doReqClient(req)
}
func doReqAuth(method, url string, body any, authSetter func(*http.Request)) *http.Response {
var br io.Reader
if body != nil {
bb, _ := json.Marshal(body)
br = bytes.NewReader(bb)
}
req, _ := http.NewRequest(method, url, br)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
authSetter(req)
return doReqClient(req)
}
func doReqClient(req *http.Request) *http.Response {
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
return resp
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"errors"
"fmt"
"io/fs"
"net/http"
"helix-proxy/internal/auth"
"helix-proxy/internal/certificate"
"helix-proxy/internal/config"
"helix-proxy/internal/proxy"
"helix-proxy/internal/store"
"helix-proxy/ui"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// newAdminRouter builds the admin UI + API chi router (no listeners started).
func newAdminRouter(st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) chi.Router {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
mountAPI(r, st, eng, cm, jwtMgr)
mountSPA(r)
return r
}
// mountSPA serves the embedded Svelte SPA or a build placeholder.
func mountSPA(r chi.Router) {
spaSub, err := fs.Sub(ui.Dist, "dist")
if err != nil {
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!doctype html>
<html><head><title>Helix Proxy</title></head>
<body style="font-family:system-ui;padding:2rem;max-width:720px;margin:0 auto">
<h1>Helix Proxy</h1>
<p>UI not built yet — run <code>make ui-build</code> then <code>make</code> (or <code>go build</code>).</p>
<p>Health: <a href="/api">/api</a> (cwd-relative data at <code>`+config.DataDir()+`</code>)</p>
<p>All paths (data, www/html, certs, logs...) default to ./data/... and ./data/www/ relative to the process CWD. Override with DATA_DIR, WWW_DIR etc.</p>
</body></html>`)
})
return
}
fileServer := http.FileServer(http.FS(spaSub))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if p != "" && p[0] == '/' {
p = p[1:]
}
if p == "" {
p = "index.html"
}
if _, statErr := fs.Stat(spaSub, p); errors.Is(statErr, fs.ErrNotExist) {
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"net/http"
"strings"
"testing"
)
func TestAdminRouter_SPAFallback(t *testing.T) {
env := newAdminTestServer(t)
resp := apiGet(t, env.BaseURL+"/api/", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("api health: %d", resp.StatusCode)
}
resp = apiGet(t, env.BaseURL+"/", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("spa root: %d", resp.StatusCode)
}
body := readBody(resp)
if !strings.Contains(body, "Helix Proxy") {
t.Errorf("spa body missing title: %q", body[:min(120, len(body))])
}
resp = apiGet(t, env.BaseURL+"/some/spa/route", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("spa route: %d", resp.StatusCode)
}
}
func TestApplyPUIDPGID(t *testing.T) {
t.Run("no env", func(t *testing.T) {
t.Setenv("PUID", "")
t.Setenv("PGID", "")
if err := applyPUIDPGID(); err != nil {
t.Errorf("empty env: %v", err)
}
})
t.Run("invalid PUID", func(t *testing.T) {
t.Setenv("PUID", "not-a-number")
t.Setenv("PGID", "1000")
if err := applyPUIDPGID(); err == nil {
t.Error("expected error for invalid PUID")
}
})
t.Run("invalid PGID", func(t *testing.T) {
t.Setenv("PUID", "1000")
t.Setenv("PGID", "bad")
if err := applyPUIDPGID(); err == nil {
t.Error("expected error for invalid PGID")
}
})
t.Run("non-root skips chown", func(t *testing.T) {
t.Setenv("PUID", "1000")
t.Setenv("PGID", "1000")
// tests run unprivileged; Getuid() != 0 => no chown, no error
if err := applyPUIDPGID(); err != nil {
t.Errorf("non-root should skip: %v", err)
}
})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"time"
"helix-proxy/internal/auth"
"helix-proxy/internal/certificate"
"helix-proxy/internal/config"
"helix-proxy/internal/proxy"
"helix-proxy/internal/store"
)
// run boots the admin API, proxy engine, HTTPS listener, and renewal loop until ctx is cancelled.
func run(ctx context.Context) error {
applyUmaskFromEnv()
if err := config.EnsureDataDirs(); err != nil {
return err
}
if err := applyPUIDPGID(); err != nil {
slog.Warn("PUID/PGID apply partial", "err", err)
}
dbPath := config.Resolve("db.bolt")
slog.Info("using data dir", "data", config.DataDir(), "db", dbPath)
if config.IsDevelopment() {
slog.Warn("PROXY_MODE=development (all letsencrypt certs will be self-signed test certs; not for production use)")
} else {
slog.Info("production mode (real LE only; no test cert simulation)")
}
st, err := store.New()
if err != nil {
return err
}
eng := proxy.NewEngine(st)
eng.ReloadFromStore()
cm := certificate.NewManager(st)
jwtMgr := auth.NewJWTManager(os.Getenv("JWT_SECRET"))
go startRenewalLoop(ctx, cm, eng)
r := newAdminRouter(st, eng, cm, jwtMgr)
adminAddr := adminListenAddr()
srv := &http.Server{
Addr: adminAddr,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
slog.Info("admin UI + API listening", "addr", adminAddr, "note", "use ADMIN_PORT=81 in privileged env or docker for real port")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
}
}()
proxyCtx, proxyCancel := context.WithCancel(ctx)
defer proxyCancel()
go func() {
if err := eng.Start(proxyCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("proxy engine stopped", "err", err)
}
}()
go func() {
httpsAddr := proxyHTTPSListenAddr()
slog.Info("starting HTTPS listener for cert-enabled hosts (pure Go tls termination)", "addr", httpsAddr)
if err := serveHTTPS(proxyCtx, eng, httpsAddr); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("https server error", "err", err)
}
}()
<-ctx.Done()
slog.Info("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
proxyCancel()
_ = srv.Shutdown(shutdownCtx)
slog.Info("stopped")
return nil
}
// startRenewalLoop periodically renews LE certs until ctx is cancelled.
func startRenewalLoop(ctx context.Context, cm *certificate.Manager, eng *proxy.Engine) {
initialDelay := 5 * time.Second
if d := os.Getenv("RENEWAL_INITIAL_DELAY"); d != "" {
if parsed, err := time.ParseDuration(d); err == nil {
initialDelay = parsed
}
}
renew := func() {
cm.ProcessRenewals()
eng.ReloadFromStore()
}
go func() {
select {
case <-ctx.Done():
return
case <-time.After(initialDelay):
renew()
}
}()
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
renew()
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"context"
"net/http"
"strconv"
"testing"
"time"
"helix-proxy/internal/config"
)
func TestRun_BootstrapAndShutdown(t *testing.T) {
t.Setenv("PROXY_MODE", "development")
t.Setenv("DISABLE_IPV6", "1")
t.Setenv("RENEWAL_INITIAL_DELAY", "1h") // avoid renewal work during short test
adminPort := pickFreePort(t)
httpPort := pickFreePort(t)
httpsPort := pickFreePort(t)
t.Setenv("ADMIN_PORT", strconv.Itoa(adminPort))
t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort))
t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort))
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx)
}()
adminAddr := "127.0.0.1:" + strconv.Itoa(adminPort)
waitForTCP(t, adminAddr, 5*time.Second)
waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpPort), 5*time.Second)
waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpsPort), 5*time.Second)
resp, err := http.Get("http://" + adminAddr + "/api/")
if err != nil {
t.Fatalf("admin health: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin health status: %d", resp.StatusCode)
}
_ = resp.Body.Close()
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("run returned error: %v", err)
}
case <-time.After(15 * time.Second):
t.Fatal("run did not exit after context cancel")
}
}
func TestRun_EnsureDataDirsFailure(t *testing.T) {
config.ResetForTest()
t.Setenv("DATA_DIR", "/nonexistent-root/helix-proxy-test-"+strconv.Itoa(pickFreePort(t)))
// Parent does not exist and cannot be created under /nonexistent-root.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := run(ctx)
if err == nil {
t.Fatal("expected error when data dirs cannot be created")
}
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"helix-proxy/internal/store"
)
func pickFreePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
func waitForTCP(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
_ = conn.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("tcp %s not ready within %s", addr, timeout)
}
func TestEngineStartup_HTTPAndHTTPS(t *testing.T) {
t.Setenv("DISABLE_IPV6", "1")
httpPort := pickFreePort(t)
httpsPort := pickFreePort(t)
t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort))
t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort))
env := newAPITestServer(t)
token := env.loginToken(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Upstream", "ok")
_, _ = io.WriteString(w, "upstream-body")
}))
t.Cleanup(upstream.Close)
u, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
host, portStr, _ := net.SplitHostPort(u.Host)
port, _ := strconv.Atoi(portStr)
domain := "startup-tls.example"
certResp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{
"provider": "letsencrypt",
"domainNames": []string{domain},
}, token)
if certResp.StatusCode != http.StatusCreated {
t.Fatalf("cert create: %d %s", certResp.StatusCode, readBody(certResp))
}
var cert store.Certificate
decodeJSON(t, certResp, &cert)
phResp := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{domain},
"forwardHost": host,
"forwardPort": port,
"forwardScheme": "http",
"certificateId": cert.ID,
"enabled": true,
}, token)
if phResp.StatusCode != http.StatusCreated {
t.Fatalf("proxy host: %d %s", phResp.StatusCode, readBody(phResp))
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
httpAddr := "127.0.0.1:" + strconv.Itoa(httpPort)
httpsAddr := proxyHTTPSListenAddr()
go func() {
_ = env.Engine.Start(ctx)
}()
go func() {
_ = serveHTTPS(ctx, env.Engine, httpsAddr)
}()
waitForTCP(t, httpAddr, 5*time.Second)
waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpsPort), 5*time.Second)
req, err := http.NewRequest(http.MethodGet, "http://"+httpAddr+"/", nil)
if err != nil {
t.Fatalf("request: %v", err)
}
req.Host = domain
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("http proxy: %v", err)
}
body := readBody(resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("http status: %d body=%s", resp.StatusCode, body)
}
if body != "upstream-body" {
t.Errorf("http body: %q", body)
}
httpsClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
ServerName: domain,
InsecureSkipVerify: true,
},
},
}
httpsReq, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:"+strconv.Itoa(httpsPort)+"/", nil)
if err != nil {
t.Fatalf("https request: %v", err)
}
httpsReq.Host = domain
httpsResp, err := httpsClient.Do(httpsReq)
if err != nil {
t.Fatalf("https proxy: %v", err)
}
httpsBody := readBody(httpsResp)
if httpsResp.StatusCode != http.StatusOK {
t.Fatalf("https status: %d body=%s", httpsResp.StatusCode, httpsBody)
}
if httpsBody != "upstream-body" {
t.Errorf("https body: %q", httpsBody)
}
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"helix-proxy/internal/auth"
"helix-proxy/internal/certificate"
"helix-proxy/internal/config"
"helix-proxy/internal/proxy"
"helix-proxy/internal/store"
"github.com/go-chi/chi/v5"
)
// testServerMode selects API-only vs full admin router (API + SPA).
type testServerMode int
const (
testServerAPI testServerMode = iota
testServerAdmin
)
type apiTestEnv struct {
BaseURL string
Store store.Store
Engine *proxy.Engine
Certs *certificate.Manager
JWT *auth.JWTManager
}
// newTestServer boots a temp store/engine and httptest server with the chosen router.
func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
t.Helper()
if _, ok := os.LookupEnv("PROXY_MODE"); !ok {
t.Setenv("PROXY_MODE", "development")
}
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
if err := config.EnsureDataDirs(); err != nil {
t.Fatalf("ensure dirs: %v", err)
}
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
eng := proxy.NewEngine(st)
eng.ReloadFromStore()
cm := certificate.NewManager(st)
jwtMgr := auth.NewJWTManager("test-jwt-secret")
var handler http.Handler
switch mode {
case testServerAdmin:
handler = newAdminRouter(st, eng, cm, jwtMgr)
default:
r := chi.NewRouter()
mountAPI(r, st, eng, cm, jwtMgr)
handler = r
}
srv := httptest.NewServer(handler)
t.Cleanup(func() {
srv.Close()
if c, ok := st.(interface{ Close() error }); ok {
_ = c.Close()
}
})
return &apiTestEnv{
BaseURL: srv.URL,
Store: st,
Engine: eng,
Certs: cm,
JWT: jwtMgr,
}
}
func newAPITestServer(t *testing.T) *apiTestEnv {
return newTestServer(t, testServerAPI)
}
func newAdminTestServer(t *testing.T) *apiTestEnv {
return newTestServer(t, testServerAdmin)
}