Files
2026-07-27 19:31:43 +08:00

235 lines
7.3 KiB
Go

package adminauth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"litepan/internal/domain"
"litepan/internal/store"
"litepan/pkg/security"
)
func newTestAuth(t *testing.T) (*Service, domain.ConfigRepository, context.Context) {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, store.Options{Memory: true})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
st := store.New(db)
_ = st.Configs.Set(ctx, KeyAdminUsername, "admin")
_ = st.Configs.Set(ctx, KeyAdminPassword, security.HashPassword("changed-secret"))
return New(st.Configs, []byte("test-secret-key-min-16b"), nil), st.Configs, ctx
}
func newBareTestAuth(t *testing.T) (*Service, domain.ConfigRepository, context.Context) {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, store.Options{Memory: true})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
st := store.New(db)
return New(st.Configs, []byte("test-secret-key-min-16b"), nil), st.Configs, ctx
}
func TestDefaultAdminPasswordIsInitializedAsHashAndMustChange(t *testing.T) {
svc, configs, ctx := newBareTestAuth(t)
username, storedPassword := svc.adminCredentials(ctx)
if username != defaultAdminUsername {
t.Fatalf("username = %q, want %q", username, defaultAdminUsername)
}
if !security.IsPasswordHash(storedPassword) {
t.Fatalf("default password should be stored as hash, got %q", storedPassword)
}
if !security.VerifyAdminPassword(storedPassword, defaultAdminPassword) {
t.Fatal("hashed default password should verify admin password")
}
persisted, ok, err := configs.Get(ctx, KeyAdminPassword)
if err != nil {
t.Fatalf("get persisted password: %v", err)
}
if !ok || persisted != storedPassword {
t.Fatalf("persisted password = %q, ok=%v, want stored hash %q", persisted, ok, storedPassword)
}
state := svc.credentialState(ctx)
if !state.MustChangePassword {
t.Fatal("default admin should still require password change")
}
if state.PasswordChangeReason != "default_credentials" {
t.Fatalf("reason = %q, want default_credentials", state.PasswordChangeReason)
}
}
func TestReadSessionExpiresAfterTimeout(t *testing.T) {
svc, configs, ctx := newTestAuth(t)
_ = configs.Set(ctx, KeySessionTimeout, "1800")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := svc.WriteSession(rec, req, Session{IsAdmin: true, Username: "admin"}, false); err != nil {
t.Fatalf("write session: %v", err)
}
cookie := rec.Result().Cookies()[0]
checkReq := httptest.NewRequest(http.MethodGet, "/", nil)
checkReq.AddCookie(cookie)
if _, ok := svc.ReadSession(checkReq); !ok {
t.Fatal("session should be valid immediately after login")
}
sess, ok := svc.ReadSession(checkReq)
if !ok || sess == nil {
t.Fatal("expected session payload")
}
sess.CreatedAt = time.Now().Add(-31 * time.Minute).Format(time.RFC3339)
rec2 := httptest.NewRecorder()
if err := svc.WriteSession(rec2, checkReq, *sess, false); err != nil {
t.Fatalf("rewrite session: %v", err)
}
expiredReq := httptest.NewRequest(http.MethodGet, "/", nil)
expiredReq.AddCookie(rec2.Result().Cookies()[0])
if _, ok := svc.ReadSession(expiredReq); ok {
t.Fatal("session should expire after configured timeout")
}
}
func TestUpdateCredentialsPreservesSessionCreatedAt(t *testing.T) {
svc, configs, ctx := newTestAuth(t)
_ = configs.Set(ctx, KeySessionTimeout, "1800")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
createdAt := time.Now().Add(-20 * time.Minute).Format(time.RFC3339)
if err := svc.WriteSession(rec, req, Session{
IsAdmin: true,
Username: "admin",
CreatedAt: createdAt,
}, false); err != nil {
t.Fatalf("write session: %v", err)
}
cookie := rec.Result().Cookies()[0]
updateReq := httptest.NewRequest(http.MethodPost, "/api/admin/update-credentials", nil)
updateReq.AddCookie(cookie)
sess, ok := svc.ReadSession(updateReq)
if !ok {
t.Fatal("expected active session before update")
}
timeout := 0.5
updateRec := httptest.NewRecorder()
if err := svc.UpdateCredentials(ctx, updateReq, updateRec, UpdateCredentialsRequest{
AdminUsername: "admin",
SessionTimeout: &timeout,
}, sess); err != nil {
t.Fatalf("update credentials: %v", err)
}
afterReq := httptest.NewRequest(http.MethodGet, "/", nil)
afterReq.AddCookie(updateRec.Result().Cookies()[0])
got, ok := svc.ReadSession(afterReq)
if !ok || got == nil {
t.Fatal("session should remain valid after settings save")
}
if got.CreatedAt != createdAt {
t.Fatalf("CreatedAt changed after settings save: got %q want %q", got.CreatedAt, createdAt)
}
}
func TestUpdateCredentialsClearsMustChangePassword(t *testing.T) {
svc, _, ctx := newTestAuth(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := svc.WriteSession(rec, req, Session{
IsAdmin: true,
Username: "admin",
MustChangePassword: true,
PasswordChangeReason: "default_credentials",
}, false); err != nil {
t.Fatalf("write session: %v", err)
}
cookie := rec.Result().Cookies()[0]
updateReq := httptest.NewRequest(http.MethodPost, "/api/admin/update-credentials", nil)
updateReq.AddCookie(cookie)
sess, ok := svc.ReadSession(updateReq)
if !ok {
t.Fatal("expected active session before update")
}
updateRec := httptest.NewRecorder()
if err := svc.UpdateCredentials(ctx, updateReq, updateRec, UpdateCredentialsRequest{
AdminUsername: "admin",
AdminPassword: "new-secret",
}, sess); err != nil {
t.Fatalf("update credentials: %v", err)
}
afterReq := httptest.NewRequest(http.MethodGet, "/", nil)
afterReq.AddCookie(updateRec.Result().Cookies()[0])
got, ok := svc.ReadSession(afterReq)
if !ok || got == nil {
t.Fatal("session should remain valid after password change")
}
if got.MustChangePassword {
t.Fatal("session must_change_password should be cleared after password upgrade")
}
st := svc.Status(ctx, afterReq)
if st.MustChangePassword {
t.Fatal("status must_change_password should be false after password upgrade")
}
}
func TestUpdateCredentialsValidationFailureDoesNotPersistAnySetting(t *testing.T) {
svc, configs, ctx := newTestAuth(t)
if err := configs.Set(ctx, KeyPublicIndexEnabled, "true"); err != nil {
t.Fatalf("set public index: %v", err)
}
publicIndexEnabled := false
invalidConcurrency := 6
err := svc.UpdateCredentials(
ctx,
httptest.NewRequest(http.MethodPost, "/api/admin/update-credentials", nil),
httptest.NewRecorder(),
UpdateCredentialsRequest{
AdminUsername: "changed_admin",
PublicIndexEnabled: &publicIndexEnabled,
UploadTaskConcurrency: &invalidConcurrency,
},
nil,
)
if err == nil {
t.Fatal("expected invalid concurrency to reject the whole update")
}
username, ok, getErr := configs.Get(ctx, KeyAdminUsername)
if getErr != nil {
t.Fatalf("get username: %v", getErr)
}
if !ok || username != "admin" {
t.Fatalf("username changed after rejected update: got %q, ok=%v", username, ok)
}
publicIndex, ok, getErr := configs.Get(ctx, KeyPublicIndexEnabled)
if getErr != nil {
t.Fatalf("get public index: %v", getErr)
}
if !ok || publicIndex != "true" {
t.Fatalf("public index changed after rejected update: got %q, ok=%v", publicIndex, ok)
}
}