All checks were successful
continuous-integration/drone/push Build is passing
Add POST /api/v1/password endpoint accepting a JSON body (username, old_password, new_password) protected by a shared Bearer token (CHANGE_API_SECRET / -change-api-secret). It verifies the current password via an LDAP bind before applying the change, matching the alps "password" plugin bearer flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
4.6 KiB
Go
162 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCheckPasswdConstraint(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pass string
|
|
wantErr bool
|
|
}{
|
|
{"valid password", "Correct1Horse", false},
|
|
{"too short", "Short1A", true},
|
|
{"exactly 12 chars", "Abcdefgh1234", false},
|
|
{"no uppercase", "correct1horse", true},
|
|
{"no lowercase", "CORRECT1HORSE", true},
|
|
{"no digit", "CorrectHorse!", true},
|
|
{"exactly 128 chars", strings.Repeat("a", 126) + "A1", false},
|
|
{"129 chars is too long", strings.Repeat("a", 127) + "A1", true},
|
|
{"very long password", strings.Repeat("Aa1", 100), true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := checkPasswdConstraint(tt.pass)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("checkPasswdConstraint(%q) error = %v, wantErr %v", tt.pass, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChangePasswordAPI exercises the request-validation paths of the JSON
|
|
// password change API that return before any LDAP connection is attempted.
|
|
func TestChangePasswordAPI(t *testing.T) {
|
|
const secret = "s3cr3t-token"
|
|
|
|
// Restore the global secret after the test.
|
|
orig := changeAPISecret
|
|
t.Cleanup(func() { changeAPISecret = orig })
|
|
|
|
tests := []struct {
|
|
name string
|
|
secret string // value of changeAPISecret for this case
|
|
authHeader string // Authorization header ("" means none)
|
|
body string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "API disabled",
|
|
secret: "",
|
|
authHeader: "Bearer " + secret,
|
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
},
|
|
{
|
|
name: "missing token",
|
|
secret: secret,
|
|
authHeader: "",
|
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "wrong token",
|
|
secret: secret,
|
|
authHeader: "Bearer nope",
|
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "malformed authorization header",
|
|
secret: secret,
|
|
authHeader: secret,
|
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
|
wantStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
secret: secret,
|
|
authHeader: "Bearer " + secret,
|
|
body: `{not json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "missing username",
|
|
secret: secret,
|
|
authHeader: "Bearer " + secret,
|
|
body: `{"old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "missing passwords",
|
|
secret: secret,
|
|
authHeader: "Bearer " + secret,
|
|
body: `{"username":"alice"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "weak new password",
|
|
secret: secret,
|
|
authHeader: "Bearer " + secret,
|
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"short"}`,
|
|
wantStatus: http.StatusNotAcceptable,
|
|
},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
changeAPISecret = tt.secret
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/password", strings.NewReader(tt.body))
|
|
if tt.authHeader != "" {
|
|
req.Header.Set("Authorization", tt.authHeader)
|
|
}
|
|
// Unique remote address per case so the shared rate limiter never trips.
|
|
req.RemoteAddr = fmt.Sprintf("192.0.2.%d:1234", i+1)
|
|
|
|
w := httptest.NewRecorder()
|
|
changePasswordAPI(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckChangeAPIAuthorization(t *testing.T) {
|
|
orig := changeAPISecret
|
|
t.Cleanup(func() { changeAPISecret = orig })
|
|
changeAPISecret = "s3cr3t-token"
|
|
|
|
tests := []struct {
|
|
name string
|
|
header string
|
|
want bool
|
|
}{
|
|
{"valid", "Bearer s3cr3t-token", true},
|
|
{"case-insensitive scheme", "bearer s3cr3t-token", true},
|
|
{"wrong token", "Bearer wrong", false},
|
|
{"missing", "", false},
|
|
{"no scheme", "s3cr3t-token", false},
|
|
{"wrong scheme", "Basic s3cr3t-token", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/password", nil)
|
|
if tt.header != "" {
|
|
req.Header.Set("Authorization", tt.header)
|
|
}
|
|
if got := checkChangeAPIAuthorization(req); got != tt.want {
|
|
t.Errorf("checkChangeAPIAuthorization(%q) = %v, want %v", tt.header, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|