204 lines
7.7 KiB
Go
204 lines
7.7 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
|
)
|
|
|
|
// TestIntegrationCredentialOperations verifies the private operation protocol:
|
|
// one frozen token, stable idempotency and audit identity from MySQL itself.
|
|
func TestIntegrationCredentialOperations(t *testing.T) {
|
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
|
if dsn == "" {
|
|
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
db, err := Open(ctx, dsn, 8, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if err := Migrate(ctx, db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var initial BootstrapResult
|
|
name := "credential-" + string(testID(t))
|
|
err = Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error {
|
|
initial = v
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
|
if err != nil || status.Revision != 1 || status.RevokedAt != nil {
|
|
t.Fatalf("initial credential status: %+v %v", status, err)
|
|
}
|
|
old, err := auth.Parse(initial.Token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, old); err != nil {
|
|
t.Fatalf("initial credential rejected: %v", err)
|
|
}
|
|
|
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{
|
|
NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1,
|
|
Action: credentialRotateAction, ValidFor: 2 * time.Hour, Reason: "scheduled rotation",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if op.Token == initial.Token || op.OperationID == "" || op.ExpiresAt == nil {
|
|
t.Fatal("prepare did not freeze a new token and expiry")
|
|
}
|
|
receipt, err := ApplyCredentialOperation(ctx, db, op)
|
|
if err != nil || receipt.Revision != 2 || receipt.Deduplicated {
|
|
t.Fatalf("rotation receipt: %+v %v", receipt, err)
|
|
}
|
|
newToken, err := auth.Parse(op.Token)
|
|
if err != nil || NewTaskStore(db).AuthorizeSubmit(ctx, old) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
|
|
t.Fatalf("credential authentication boundary failed: %v", err)
|
|
}
|
|
|
|
replayed, err := ApplyCredentialOperation(ctx, db, op)
|
|
if err != nil || !replayed.Deduplicated || replayed.OperationID != receipt.OperationID || replayed.Revision != receipt.Revision {
|
|
t.Fatalf("stable operation replay: %+v %v", replayed, err)
|
|
}
|
|
changed := op
|
|
changed.Token, _, err = auth.Issue(initial.NamespaceID, initial.CredentialID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ApplyCredentialOperation(ctx, db, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
|
t.Fatalf("changed operation file was accepted: %v", err)
|
|
}
|
|
|
|
// Revocation remains available for a disabled application so an incident can
|
|
// close credentials even when ordinary authentication is already unavailable.
|
|
if _, err := db.ExecContext(ctx, "UPDATE orbit_applications SET enabled=FALSE WHERE id=?", initial.ApplicationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
revoke, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{
|
|
NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 2,
|
|
Action: credentialRevokeAction, Reason: "incident response",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
revoked, err := ApplyCredentialOperation(ctx, db, revoke)
|
|
if err != nil || revoked.Revision != 3 || revoked.RevokedAt == nil {
|
|
t.Fatalf("revoke receipt: %+v %v", revoked, err)
|
|
}
|
|
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, newToken); fault.Code(err) != "UNAUTHENTICATED" {
|
|
t.Fatalf("revoked token remained usable: %v", err)
|
|
}
|
|
|
|
var audits int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action IN ('credential.rotate','credential.revoke')`, initial.NamespaceID, initial.CredentialID).Scan(&audits); err != nil || audits != 2 {
|
|
t.Fatalf("credential audit count=%d err=%v", audits, err)
|
|
}
|
|
var actor, before, after string
|
|
if err := db.QueryRowContext(ctx, `SELECT actor_id,`+"`before`"+`,`+"`after`"+` FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?`, initial.NamespaceID, op.OperationID).Scan(&actor, &before, &after); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(actor, "mysql:") || actor == string(initial.CredentialID) || strings.Contains(before+after, op.Token) || strings.Contains(before+after, "secret_digest") {
|
|
t.Fatalf("audit leaked or misidentified operator: actor=%q before=%s after=%s", actor, before, after)
|
|
}
|
|
var stored []byte
|
|
if err := db.QueryRowContext(ctx, `SELECT response FROM orbit_idempotency_keys WHERE namespace_id=? AND id=?`, initial.NamespaceID, op.OperationID).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var saved CredentialChangeReceipt
|
|
if err := json.Unmarshal(stored, &saved); err != nil || saved.OperationID != op.OperationID {
|
|
t.Fatalf("stored receipt: %s %v", stored, err)
|
|
}
|
|
t.Log("credential operation file froze one token, replay returned the original receipt, tampering conflicted, and disabled-scope revocation preserved audit identity")
|
|
}
|
|
|
|
func TestIntegrationCredentialCommitFaults(t *testing.T) {
|
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
|
if dsn == "" {
|
|
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
db, err := Open(ctx, dsn, 8, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if err := Migrate(ctx, db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, after := range []bool{false, true} {
|
|
name := "credential-commit-" + string(testID(t))
|
|
var initial BootstrapResult
|
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, GraceFor: time.Hour, Reason: "commit boundary"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proxy, proxiedDSN := newCommitFaultProxy(t, dsn)
|
|
faultDB, err := Open(ctx, proxiedDSN, 2, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after {
|
|
proxy.mode.Store(2)
|
|
} else {
|
|
proxy.mode.Store(1)
|
|
}
|
|
_, applyErr := ApplyCredentialOperation(ctx, faultDB, op)
|
|
proxy.expect(t, after, applyErr)
|
|
faultDB.Close()
|
|
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantRevision := uint64(1)
|
|
if after {
|
|
wantRevision = 2
|
|
}
|
|
oldToken, _ := auth.Parse(initial.Token)
|
|
newToken, _ := auth.Parse(op.Token)
|
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, oldToken); err != nil {
|
|
t.Fatal("commit ambiguity invalidated old token", err)
|
|
}
|
|
if accepted := NewTaskStore(db).AuthorizeSubmit(ctx, newToken) == nil; accepted != after {
|
|
t.Fatal("commit boundary accepted wrong generation")
|
|
}
|
|
if status.Revision != wantRevision {
|
|
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
|
|
}
|
|
receipt, err := ApplyCredentialOperation(ctx, db, op)
|
|
if err != nil || receipt.Revision != 2 || receipt.Deduplicated != after {
|
|
t.Fatalf("commit %v reconciliation receipt=%+v err=%v", after, receipt, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testID(t *testing.T) core.ID {
|
|
t.Helper()
|
|
id, err := core.NewID()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|