Scert/backend/internal/httpapi/local_recovery_disposition_test.go

251 lines
9.4 KiB
Go

package httpapi
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/localreset"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
type localDispositionJournal struct {
deployment.TransactionJournal
mu sync.Mutex
record deployment.TransactionRecord
item deployment.RecoveryDisposition
begins int
finishes int
queries int
failFinish bool
}
func (j *localDispositionJournal) ListPendingDeploymentTransactions(context.Context) ([]deployment.TransactionRecord, error) {
return []deployment.TransactionRecord{j.record}, nil
}
func (j *localDispositionJournal) BeginRecoveryDisposition(_ context.Context, intent deployment.RecoveryDispositionIntent) (deployment.RecoveryDisposition, bool, error) {
j.mu.Lock()
defer j.mu.Unlock()
j.begins++
if j.item.Intent.ID != "" {
return j.item, false, nil
}
now := time.Now().UTC()
j.item = deployment.RecoveryDisposition{Intent: intent, Status: "running", CreatedAt: now, UpdatedAt: now}
return j.item, true, nil
}
func (j *localDispositionJournal) FinishRecoveryDisposition(_ context.Context, id, result string) (deployment.RecoveryDisposition, error) {
j.mu.Lock()
defer j.mu.Unlock()
j.finishes++
if j.failFinish {
return deployment.RecoveryDisposition{}, errors.New("secret-audit-error")
}
j.item.Status, j.item.ResultCode, j.item.UpdatedAt = "succeeded", result, time.Now().UTC()
if result == "rejected" || result == "uncertain" {
j.item.Status = result
}
return j.item, nil
}
func (j *localDispositionJournal) GetRecoveryDisposition(context.Context, string) (deployment.RecoveryDisposition, error) {
j.mu.Lock()
defer j.mu.Unlock()
j.queries++
return j.item, nil
}
func (*localDispositionJournal) HasRecoveryClaim(context.Context, string) (bool, error) {
return false, nil
}
type localDispositionRemote struct{ deployment.Client }
func (localDispositionRemote) Exists(context.Context, string) (bool, error) { return false, nil }
func (localDispositionRemote) Close() error { return nil }
func (localDispositionRemote) CreateOwnedLock(context.Context, string, string) error {
return errors.New("unexpected mutation")
}
func (localDispositionRemote) ReadLockOwner(context.Context, string) (string, error) {
return "", errors.New("absent")
}
func (localDispositionRemote) FileDigest(context.Context, string) (string, error) {
return "", errors.New("absent")
}
type localDispositionFactory struct{}
func (localDispositionFactory) Connect(context.Context, deployment.Server) (deployment.Client, error) {
return localDispositionRemote{}, nil
}
type localDispositionServerRepo struct {
repository.ServerRepository
server deployment.Server
}
func (r localDispositionServerRepo) GetServerByID(context.Context, string) (deployment.Server, error) {
return r.server, nil
}
func localDispositionFixture(t *testing.T) (*Server, *localResetSession, *localDispositionJournal) {
t.Helper()
server := newLocalResetTestServer(t)
target := deployment.Server{ID: "server-1", Name: "server-1", Address: "192.0.2.1", Port: 22, Username: "deploy", AuthMethod: deployment.SSHAuthPassword,
PasswordRef: "secret://ssh/password/test", HostKeyFingerprint: "SHA256:test", CertificatePath: "/cert.pem", CertificateChainPath: "/chain.pem", PrivateKeyPath: "/key.pem", WebServer: deployment.WebServerNginx}.Normalize()
journal := &localDispositionJournal{record: deployment.TransactionRecord{ID: "tx-1", ServerID: target.ID, Phase: "locked", Status: deployment.TransactionStatusRecoveryRequired,
Paths: []string{target.CertificatePath, target.CertificateChainPath, target.PrivateKeyPath}}}
server.recoveryInspection = deployment.NewServiceWithJournal(localDispositionFactory{}, journal)
server.servers = localDispositionServerRepo{server: target}
session := localDispositionNewSession(t, server)
return server, session, journal
}
func localDispositionNewSession(t *testing.T, server *Server) *localResetSession {
t.Helper()
session, err := server.localResetFactory(func(context.Context, string, string) error { t.Error("unexpected mail"); return nil })(t.Context(), localreset.Peer{Identity: "uid:0"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(session.Close)
return session.(*localResetSession)
}
func TestLocalDispositionRecordAndReconnectQuery(t *testing.T) {
server, session, journal := localDispositionFixture(t)
if !session.DispositionAvailable() {
t.Fatal("missing capability")
}
prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord)
if err != nil || len(prepared.Token) != 64 {
t.Fatalf("prepare: %+v %v", prepared, err)
}
result, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token)
if err != nil || result.Status != "succeeded" || result.ResultCode != "observed" {
t.Fatalf("execute: %+v %v", result, err)
}
if journal.item.Intent.Operator != "uid:0" || journal.record.Status != deployment.TransactionStatusRecoveryRequired || journal.record.Evidence != nil {
t.Fatal("identity or original transaction changed")
}
if _, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token); err == nil {
t.Fatal("confirmation replayed")
}
next := localDispositionNewSession(t, server)
queried, err := next.GetRecoveryDisposition(t.Context(), prepared.DispositionID)
if err != nil || queried != result {
t.Fatalf("query: %+v %v", queried, err)
}
if journal.begins != 1 || journal.finishes != 1 || journal.queries != 1 {
t.Fatal("query replayed execution")
}
if err := next.Select(t.Context(), "email"); err == nil {
t.Fatal("query switched to password reset")
}
}
func TestLocalDispositionInvalidSessionAndConfirmationNeverBegin(t *testing.T) {
for _, mode := range []string{"expired", "cancelled", "changed-admin", "closed", "reset-stage", "wrong-id", "wrong-token", "changed-peer", "replaced-confirmation"} {
t.Run(mode, func(t *testing.T) {
server, session, journal := localDispositionFixture(t)
prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
switch mode {
case "expired":
session.expires = time.Time{}
case "cancelled":
cancel()
case "changed-admin":
server.config.Admin.Email = "changed@example.test"
case "closed":
session.Close()
case "reset-stage":
session.stage = 1
case "wrong-id":
prepared.DispositionID = strings.Repeat("a", 64)
case "wrong-token":
prepared.Token = strings.Repeat("a", 64)
case "changed-peer":
session.peer.Identity = "uid:123"
case "replaced-confirmation":
if _, err := session.PrepareRecovery(ctx, "tx-1", deployment.RecoveryActionRecord); err != nil {
t.Fatal(err)
}
}
if _, err := session.ExecuteRecovery(ctx, prepared.DispositionID, prepared.Token); err == nil {
t.Fatal("invalid execution accepted")
}
if _, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token); err == nil {
t.Fatal("failed confirmation reusable")
}
if journal.begins != 0 || journal.finishes != 0 {
t.Fatal("invalid request wrote intent")
}
if session.recoveryConfirmation.DispositionID() != "" {
t.Fatal("confirmation retained")
}
})
}
}
func TestLocalDispositionConcurrentExecuteOnce(t *testing.T) {
_, session, journal := localDispositionFixture(t)
prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token)
}()
}
wg.Wait()
if journal.begins != 1 || journal.finishes != 1 {
t.Fatal("concurrent execution duplicated")
}
}
func TestLocalDispositionAuditFailureReturnsRunning(t *testing.T) {
server, session, journal := localDispositionFixture(t)
journal.failFinish = true
prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord)
if err != nil {
t.Fatal(err)
}
result, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token)
if err != nil || result.Status != "running" || result.ResultCode != "" {
t.Fatalf("false success: %+v %v", result, err)
}
next := localDispositionNewSession(t, server)
if result, err := next.GetRecoveryDisposition(t.Context(), prepared.DispositionID); err != nil || result.Status != "running" {
t.Fatalf("query: %+v %v", result, err)
}
}
func TestLocalDispositionMissingEvidenceAndReaderOnly(t *testing.T) {
for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} {
_, session, journal := localDispositionFixture(t)
if _, err := session.PrepareRecovery(t.Context(), "tx-1", action); err == nil {
t.Fatal("missing evidence authorized mutation")
}
if journal.begins != 0 || session.recoveryConfirmation.DispositionID() != "" {
t.Fatal("invalid confirmation retained")
}
}
server := newLocalResetTestServer(t)
session := newLocalRecoverySession(t, server, &testLocalRecoveryReader{})
if session.DispositionAvailable() {
t.Fatal("reader advertised writes")
}
if _, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord); err == nil {
t.Fatal("reader accepted action")
}
}