251 lines
9.4 KiB
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")
|
|
}
|
|
}
|