13070a275d
Closes #2: bootstrap JWTs cannot mutate admin APIs except change-password, production requires ADMIN_PASSWORD on first boot, admin binds loopback.
168 lines
5.2 KiB
Go
168 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"helix-proxy/internal/config"
|
|
"helix-proxy/internal/store"
|
|
)
|
|
|
|
func newBootstrapAPIServer(t *testing.T) *apiTestEnv {
|
|
t.Helper()
|
|
t.Setenv("PROXY_MODE", "development")
|
|
t.Setenv("ADMIN_PASSWORD", "")
|
|
return newAPITestServer(t)
|
|
}
|
|
|
|
func TestBootstrapLock_MutatingAdminForbidden(t *testing.T) {
|
|
env := newBootstrapAPIServer(t)
|
|
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
}
|
|
decodeJSON(t, resp, &out)
|
|
if out.Token == "" {
|
|
t.Fatal("empty bootstrap token")
|
|
}
|
|
tok := out.Token
|
|
|
|
mutating := []struct {
|
|
method string
|
|
path string
|
|
body any
|
|
}{
|
|
{http.MethodPost, "/api/proxy-hosts", map[string]any{
|
|
"domainNames": []string{"boot.example"},
|
|
"forwardHost": "127.0.0.1",
|
|
"forwardPort": 8080,
|
|
"forwardScheme": "http",
|
|
}},
|
|
{http.MethodPost, "/api/settings", map[string]any{"default_site": "404"}},
|
|
{http.MethodPost, "/api/access-lists", map[string]any{"name": "boot"}},
|
|
{http.MethodPost, "/api/streams", map[string]any{"incomingPort": 9000, "forwardingHost": "127.0.0.1", "forwardingPort": 9001}},
|
|
}
|
|
for _, tc := range mutating {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
r := apiRequest(t, tc.method, env.BaseURL+tc.path, tok, tc.body)
|
|
assertStatus(t, r, http.StatusForbidden)
|
|
})
|
|
}
|
|
|
|
assertStatus(t, apiGet(t, env.BaseURL+"/api/users/me", tok), http.StatusOK)
|
|
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", tok), http.StatusOK)
|
|
}
|
|
|
|
func TestBootstrapLock_ChangePasswordThenDefaultFails(t *testing.T) {
|
|
env := newBootstrapAPIServer(t)
|
|
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
}
|
|
decodeJSON(t, resp, &out)
|
|
|
|
ch := apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
|
|
"currentPassword": defaultAdminPassword,
|
|
"newPassword": "changed-after-bootstrap",
|
|
}, out.Token)
|
|
assertStatus(t, ch, http.StatusOK)
|
|
|
|
fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
|
assertStatus(t, fail, http.StatusUnauthorized)
|
|
|
|
okLogin := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "changed-after-bootstrap"}, "")
|
|
if okLogin.StatusCode != http.StatusOK {
|
|
t.Fatalf("new password login: %d %s", okLogin.StatusCode, readBody(okLogin))
|
|
}
|
|
var out2 struct {
|
|
Token string `json:"token"`
|
|
}
|
|
decodeJSON(t, okLogin, &out2)
|
|
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
|
"domainNames": []string{"after-change.example"},
|
|
"forwardHost": "127.0.0.1",
|
|
"forwardPort": 8080,
|
|
"forwardScheme": "http",
|
|
}, out2.Token)
|
|
if create.StatusCode != http.StatusCreated {
|
|
t.Fatalf("full token after change should mutate: %d %s", create.StatusCode, readBody(create))
|
|
}
|
|
_ = create.Body.Close()
|
|
|
|
locked := apiPost(t, env.BaseURL+"/api/settings", map[string]any{"default_site": "404"}, out.Token)
|
|
assertStatus(t, locked, http.StatusForbidden)
|
|
}
|
|
|
|
func TestDevTestsBootWithAdminPassword(t *testing.T) {
|
|
t.Setenv("PROXY_MODE", "development")
|
|
env := newAPITestServer(t)
|
|
if adminPasswordUnset(env.Store) {
|
|
t.Fatal("test env should store ADMIN_PASSWORD hash")
|
|
}
|
|
token := env.loginToken(t)
|
|
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
|
"domainNames": []string{"dev-admin.example"},
|
|
"forwardHost": "127.0.0.1",
|
|
"forwardPort": 8080,
|
|
"forwardScheme": "http",
|
|
}, token)
|
|
if create.StatusCode != http.StatusCreated {
|
|
t.Fatalf("dev ADMIN_PASSWORD token should be fully privileged: %d %s", create.StatusCode, readBody(create))
|
|
}
|
|
_ = create.Body.Close()
|
|
|
|
fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
|
assertStatus(t, fail, http.StatusUnauthorized)
|
|
}
|
|
|
|
func TestApplyInitialAdminPassword_RejectsDefault(t *testing.T) {
|
|
t.Setenv("PROXY_MODE", "development")
|
|
t.Setenv("ADMIN_PASSWORD", defaultAdminPassword)
|
|
tmp := t.TempDir()
|
|
config.ResetForTest()
|
|
t.Setenv("DATA_DIR", tmp)
|
|
if err := config.EnsureDataDirs(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c, ok := st.(interface{ Close() error }); ok {
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
}
|
|
err = applyInitialAdminPassword(st)
|
|
if err == nil || !strings.Contains(err.Error(), "default") {
|
|
t.Fatalf("expected default ADMIN_PASSWORD error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyInitialAdminPassword_ProductionRequiresEnv(t *testing.T) {
|
|
t.Setenv("PROXY_MODE", "production")
|
|
t.Setenv("ADMIN_PASSWORD", "")
|
|
tmp := t.TempDir()
|
|
config.ResetForTest()
|
|
t.Setenv("DATA_DIR", tmp)
|
|
if err := config.EnsureDataDirs(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := store.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c, ok := st.(interface{ Close() error }); ok {
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
}
|
|
err = applyInitialAdminPassword(st)
|
|
if err == nil || !strings.Contains(err.Error(), "ADMIN_PASSWORD") {
|
|
t.Fatalf("expected refuse without ADMIN_PASSWORD, got %v", err)
|
|
}
|
|
}
|