275 lines
13 KiB
Go
275 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
func otpUser(t *testing.T, st *store.Store, owner, password string) {
|
|
t.Helper()
|
|
hash, err := store.HashPassword(password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount(owner, "", hash, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword(owner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func issueOTP(t *testing.T, h http.Handler, admin, owner string) string {
|
|
t.Helper()
|
|
rec := do(h, "POST", "/api/admin/users/"+owner+"/one-time-password", `{}`, admin)
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("issue one-time password: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out struct {
|
|
Owner string `json:"owner"`
|
|
ID string `json:"id"`
|
|
OneTimePassword string `json:"oneTimePassword"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Owner != owner || out.ID == "" || out.OneTimePassword == "" {
|
|
t.Fatalf("issue response: %+v %v", out, err)
|
|
}
|
|
return out.OneTimePassword
|
|
}
|
|
|
|
func loginBody(owner, password string) string {
|
|
b, _ := json.Marshal(map[string]string{"owner": owner, "password": password})
|
|
return string(b)
|
|
}
|
|
|
|
func TestAdminOneTimePasswordLoginForcesReplacement(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
otpUser(t, st, "olivia", "olivia-normal-1")
|
|
normal := do(h, "POST", "/api/auth/login", loginBody("olivia", "olivia-normal-1"), "")
|
|
if normal.Code != http.StatusOK {
|
|
t.Fatalf("normal login: %d %s", normal.Code, normal.Body.String())
|
|
}
|
|
otp := issueOTP(t, h, admin, "olivia")
|
|
if rec := do(h, "GET", "/api/me", "", cookieOf(normal)); rec.Code != http.StatusOK {
|
|
t.Fatalf("issuance revoked a normal session: %d", rec.Code)
|
|
}
|
|
login := do(h, "POST", "/api/auth/login", loginBody("olivia", otp), "")
|
|
body := login.Body.String()
|
|
if login.Code != http.StatusOK || !strings.Contains(body, `"sessionScope":"set_password_only"`) ||
|
|
!strings.Contains(body, `"passwordChangeMode":"one_time_password"`) || !strings.Contains(body, `"mustChangePassword":true`) {
|
|
t.Fatalf("one-time password login: %d %s", login.Code, body)
|
|
}
|
|
restricted := cookieOf(login)
|
|
if rec := do(h, "GET", "/api/projects", "", restricted); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("restricted session reached business API: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"passwordChangeMode":"one_time_password"`) {
|
|
t.Fatalf("me on restricted session: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/login", loginBody("olivia", otp), ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("one-time password reused: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
done := do(h, "POST", "/api/auth/password", `{"next":"olivia-replaced-2"}`, restricted)
|
|
if done.Code != http.StatusOK || !strings.Contains(done.Body.String(), `"reloginRequired":true`) {
|
|
t.Fatalf("replacement: %d %s", done.Code, done.Body.String())
|
|
}
|
|
if rec := do(h, "GET", "/api/me", "", cookieOf(normal)); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("old normal session survived replacement: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/login", loginBody("olivia", "olivia-normal-1"), ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("old normal password survived replacement: %d", rec.Code)
|
|
}
|
|
fresh := do(h, "POST", "/api/auth/login", loginBody("olivia", "olivia-replaced-2"), "")
|
|
if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) || !strings.Contains(fresh.Body.String(), `"mustChangePassword":false`) {
|
|
t.Fatalf("replaced password login: %d %s", fresh.Code, fresh.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNormalLoginInvalidatesAdminOneTimePasswordWithoutForcedChange(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
otpUser(t, st, "nora", "nora-normal-1")
|
|
otp := issueOTP(t, h, admin, "nora")
|
|
normal := do(h, "POST", "/api/auth/login", loginBody("nora", "nora-normal-1"), "")
|
|
if normal.Code != http.StatusOK || !strings.Contains(normal.Body.String(), `"sessionScope":"full"`) || !strings.Contains(normal.Body.String(), `"mustChangePassword":false`) {
|
|
t.Fatalf("normal login after issuance must stay full: %d %s", normal.Code, normal.Body.String())
|
|
}
|
|
if rec := do(h, "GET", "/api/projects", "", cookieOf(normal)); rec.Code != http.StatusOK {
|
|
t.Fatalf("normal session gated: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/login", loginBody("nora", otp), ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("one-time password survived normal login: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNormalLoginRevokesExistingOneTimePasswordRestrictedSession(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
otpUser(t, st, "ivy", "ivy-normal-1")
|
|
otp := issueOTP(t, h, admin, "ivy")
|
|
restrictedLogin := do(h, "POST", "/api/auth/login", loginBody("ivy", otp), "")
|
|
if restrictedLogin.Code != http.StatusOK || !strings.Contains(restrictedLogin.Body.String(), `"passwordChangeMode":"one_time_password"`) {
|
|
t.Fatalf("otp restricted login: %d %s", restrictedLogin.Code, restrictedLogin.Body.String())
|
|
}
|
|
restricted := cookieOf(restrictedLogin)
|
|
normal := do(h, "POST", "/api/auth/login", loginBody("ivy", "ivy-normal-1"), "")
|
|
if normal.Code != http.StatusOK || !strings.Contains(normal.Body.String(), `"sessionScope":"full"`) {
|
|
t.Fatalf("normal login: %d %s", normal.Code, normal.Body.String())
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/password", `{"next":"ivy-reset-2"}`, restricted); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("restricted session survived normal login for password endpoint: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("restricted session survived normal login for me endpoint: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminOneTimePasswordIssuanceBoundaries(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
otpUser(t, st, "bob", "bob-normal-01")
|
|
otpUser(t, st, "carl", "carl-normal-1")
|
|
bob := do(h, "POST", "/api/auth/login", loginBody("bob", "bob-normal-01"), "")
|
|
if bob.Code != http.StatusOK {
|
|
t.Fatalf("bob login: %d", bob.Code)
|
|
}
|
|
if rec := do(h, "POST", "/api/admin/users/carl/one-time-password", `{}`, cookieOf(bob)); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("non-admin issued: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := do(h, "POST", "/api/admin/users/missing/one-time-password", `{}`, admin); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("missing target: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := do(h, "POST", "/api/admin/users/carl/one-time-password", `{"password":"chosen"}`, admin); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("administrator-chosen password accepted: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
otp := issueOTP(t, h, admin, "carl")
|
|
if rec := do(h, "POST", "/api/auth/login", loginBody("bob", otp), ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("one-time password used by another owner: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "GET", "/api/projects", "", ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous request: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/login", loginBody("carl", otp), ""); rec.Code != http.StatusOK {
|
|
t.Fatalf("owner one-time login: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminOneTimePasswordInitializesPendingAccount(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
account, err := st.CreatePendingAccount("admin", "pending", store.AccountProfile{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otp := issueOTP(t, h, admin, "pending")
|
|
login := do(h, "POST", "/api/auth/login", loginBody("pending", otp), "")
|
|
if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"one_time_password"`) ||
|
|
!strings.Contains(login.Body.String(), `"sessionScope":"set_password_only"`) {
|
|
t.Fatalf("pending otp login: %d %s", login.Code, login.Body.String())
|
|
}
|
|
restricted := cookieOf(login)
|
|
if rec := do(h, "POST", "/api/auth/password", `{"next":"pending-pass-2"}`, restricted); rec.Code != http.StatusOK {
|
|
t.Fatalf("pending completion: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
fresh := do(h, "POST", "/api/auth/login", loginBody("pending", "pending-pass-2"), "")
|
|
if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) {
|
|
t.Fatalf("pending normal login: %d %s", fresh.Code, fresh.Body.String())
|
|
}
|
|
view := do(h, "GET", "/api/admin/users/"+account.ID, "", admin)
|
|
if view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `"initializationState":"initialized"`) ||
|
|
!strings.Contains(view.Body.String(), `"passwordSet":true`) {
|
|
t.Fatalf("pending account state after completion: %d %s", view.Code, view.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminOneTimePasswordInitializesHistoricalPasswordlessAccount(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
if err := st.CreateAccount("hank", "", "", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := st.GetAccountByOwner("hank")
|
|
if err != nil || account.HasPassword || !account.Enabled {
|
|
t.Fatalf("historical passwordless fixture: %+v %v", account, err)
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/login", loginBody("hank", ""), ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("passwordless account logged in without a credential: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
otp := issueOTP(t, h, admin, "hank")
|
|
login := do(h, "POST", "/api/auth/login", loginBody("hank", otp), "")
|
|
body := login.Body.String()
|
|
if login.Code != http.StatusOK || !strings.Contains(body, `"sessionScope":"set_password_only"`) ||
|
|
!strings.Contains(body, `"passwordChangeMode":"one_time_password"`) || !strings.Contains(body, `"mustChangePassword":true`) {
|
|
t.Fatalf("initialization login: %d %s", login.Code, body)
|
|
}
|
|
restricted := cookieOf(login)
|
|
if rec := do(h, "GET", "/api/projects", "", restricted); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("initialization session reached business API: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "GET", "/api/me/profile", "", restricted); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("initialization session reached profile API: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/password", `{"next":"short"}`, restricted); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("short initialization password accepted: %d", rec.Code)
|
|
}
|
|
done := do(h, "POST", "/api/auth/password", `{"next":"hank-real-pass-1"}`, restricted)
|
|
if done.Code != http.StatusOK || !strings.Contains(done.Body.String(), `"reloginRequired":true`) {
|
|
t.Fatalf("initialization password: %d %s", done.Code, done.Body.String())
|
|
}
|
|
if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("initialization session survived completion: %d", rec.Code)
|
|
}
|
|
view := do(h, "GET", "/api/admin/users/"+account.ID, "", admin)
|
|
if view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `"passwordSet":true`) ||
|
|
!strings.Contains(view.Body.String(), `"initializationState":"initialized"`) {
|
|
t.Fatalf("admin view after initialization: %d %s", view.Code, view.Body.String())
|
|
}
|
|
fresh := do(h, "POST", "/api/auth/login", loginBody("hank", "hank-real-pass-1"), "")
|
|
if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) || !strings.Contains(fresh.Body.String(), `"mustChangePassword":false`) {
|
|
t.Fatalf("real password login: %d %s", fresh.Code, fresh.Body.String())
|
|
}
|
|
if rec := do(h, "GET", "/api/projects", "", cookieOf(fresh)); rec.Code != http.StatusOK {
|
|
t.Fatalf("initialized account gated: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryProposalRoutesAreRemoved(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
h := srv.Handler()
|
|
admin := bootAdmin(t, h, st)
|
|
account, err := st.GetAccountByOwner("admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, route := range []struct{ method, path, body string }{
|
|
{"POST", "/api/admin/users/" + account.ID + "/recovery-proposals", `{"expectedStateVersion":1,"idempotencyKey":"k"}`},
|
|
{"GET", "/api/admin/users/" + account.ID + "/recovery-proposals", ""},
|
|
{"GET", "/api/me/recovery-proposals", ""},
|
|
{"POST", "/api/auth/recovery-proposals/rp_x/confirm", `{"expectedVersion":1}`},
|
|
{"POST", "/api/auth/recovery-proposals/rp_x/rotate-grant", `{"expectedVersion":1,"expectedGeneration":1}`},
|
|
{"POST", "/api/auth/recovery-grants/redeem", `{"key":"alk_x"}`},
|
|
{"POST", "/api/auth/login-key/redeem", `{"key":"alk_x"}`},
|
|
{"POST", "/api/admin/users/admin/login-keys", `{}`},
|
|
{"GET", "/api/admin/users/admin/login-keys", ""},
|
|
{"DELETE", "/api/admin/users/admin/login-keys/lk_x", ""},
|
|
{"POST", "/api/admin/users/admin/password", `{}`},
|
|
} {
|
|
if rec := do(h, route.method, route.path, route.body, admin); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("%s %s still served: %d %s", route.method, route.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"alk_x"}`, ""); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("legacy redeem remained an unauthenticated entrance: %d", rec.Code)
|
|
}
|
|
}
|