Files
orbit/internal/storage/mysql/credential_integration_test.go

196 lines
7.3 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, 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
}
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
}