From 47663e2be3f9913b787d4a20fad2ab72483130b7 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:50:35 +0200 Subject: [PATCH] test(service): cover password-change partial-success contract (W2-2) Revocation failure: no error, audit still written, RevokeFailed set, password committed. Transient failure: absorbed by exactly one retry. Co-Authored-By: Claude Fable 5 --- Server/service/user_test.go | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Server/service/user_test.go diff --git a/Server/service/user_test.go b/Server/service/user_test.go new file mode 100644 index 00000000..34c7674f --- /dev/null +++ b/Server/service/user_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "errors" + "slices" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/store" +) + +// pwStore wraps MemStore with controllable DeleteOtherSessions behavior and +// audit capture, so the committed-password partial-success contract (W2-2) +// is testable. +type pwStore struct { + *store.MemStore + failRevokes int // number of DeleteOtherSessions calls that fail before succeeding + revokeCalls int + audits []string +} + +func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { + f.revokeCalls++ + if f.revokeCalls <= f.failRevokes { + return 0, errors.New("session table locked") + } + return 2, nil +} + +func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error { + f.audits = append(f.audits, action) + return nil +} + +// TestChangePassword_RevokeFailureIsPartialSuccess locks the W2-2 contract: +// once the password is committed, revocation failure must never surface as an +// error (the old password is dead; a "failed" report walks the user into the +// confirm lockout), and the audit row must still be written. +func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { + ms := store.NewMemStore() + ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{MemStore: ms, failRevokes: 99} + svc := NewUserService(fs) + + res, err := svc.ChangePassword(7, "newhash", 1) + if err != nil { + t.Fatalf("committed password change must not return an error: %v", err) + } + if !res.RevokeFailed { + t.Fatal("RevokeFailed should be set when revocation keeps failing") + } + if u, _ := ms.GetUserByID(7); u.PasswordHash != "newhash" { + t.Fatal("password should be committed") + } + if !slices.Contains(fs.audits, "password_change") { + t.Fatal("audit row must be written even when revocation fails") + } +} + +// TestChangePassword_RetryRecoversRevocation: a single transient revocation +// failure is absorbed by the bounded compensating retry. +func TestChangePassword_RetryRecoversRevocation(t *testing.T) { + ms := store.NewMemStore() + ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{MemStore: ms, failRevokes: 1} + svc := NewUserService(fs) + + res, err := svc.ChangePassword(7, "newhash", 1) + if err != nil { + t.Fatalf("ChangePassword: %v", err) + } + if res.RevokeFailed { + t.Fatal("retry should have recovered the revocation") + } + if res.SessionsRevoked != 2 { + t.Fatalf("SessionsRevoked = %d, want 2", res.SessionsRevoked) + } + if fs.revokeCalls != 2 { + t.Fatalf("expected exactly one retry (2 calls), got %d", fs.revokeCalls) + } +}