feat: support permanent credentials and overlapping token rotation

This commit is contained in:
2026-09-27 11:45:15 +08:00
parent 87aa710013
commit 385a618154
29 changed files with 718 additions and 161 deletions

View File

@@ -0,0 +1,283 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
func overlapDB(t *testing.T) (context.Context, *sql.DB) {
t.Helper()
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
db, err := Open(ctx, dsn, 12, 3)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
return ctx, db
}
func TestIntegrationCredentialOverlap(t *testing.T) {
ctx, db := overlapDB(t)
f := newExecutionFixture(t, ctx, db)
old := f.token
// 先使用有限有效期,再原地改长期;在途授权、秘密及绑定均不改变。
change := func(action string, revision uint64, grace time.Duration) CredentialOperation {
t.Helper()
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: revision, Action: action, GraceFor: grace, Reason: "credential overlap regression"})
if err != nil {
t.Fatal(err)
}
return op
}
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 DAY) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
task := f.submit(1)
_, ref := f.claim()
if _, err := f.a.Start(ctx, old, f.binding, testID(t), ref); err != nil {
t.Fatal(err)
}
expiry := change(credentialExpiryAction, 1, 0)
receipt, err := ApplyCredentialOperation(ctx, db, expiry)
if err != nil || receipt.ExpiresAt != nil || expiry.Token != "" {
t.Fatalf("expiry update: %v", err)
}
if err := f.a.Authorize(ctx, old); err != nil {
t.Fatal("original token lost after expiry update", err)
}
rotation := change(credentialRotateAction, 2, 7*24*time.Hour)
var wg sync.WaitGroup
receipts := make([]CredentialChangeReceipt, 2)
errs := make([]error, 2)
for i := range receipts {
wg.Add(1)
go func(i int) { defer wg.Done(); receipts[i], errs[i] = ApplyCredentialOperation(ctx, db, rotation) }(i)
}
wg.Wait()
if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated {
t.Fatalf("concurrent replay: %v", errs)
}
current, _ := auth.Parse(rotation.Token)
for _, token := range []auth.Token{old, current} {
if err := f.a.Authorize(ctx, token); err != nil {
t.Fatal("overlap auth rejected", err)
}
}
status, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
if err != nil || status.ExpiresAt != nil || status.PreviousValidUntil == nil {
t.Fatal("missing permanent/overlap status", err)
}
originalUntil := *status.PreviousValidUntil
// 过渡期间第二次轮换必须冲突,而不能挤掉仍在工作的上一代。
blocked := change(credentialRotateAction, 3, time.Hour)
if _, err := ApplyCredentialOperation(ctx, db, blocked); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("active predecessor overwritten", err)
}
renewed, err := f.a.Renew(ctx, old, f.binding, testID(t), ref)
if err != nil || !renewed.Lease.Active {
t.Fatal("in-flight old-token renew failed", err)
}
if _, err := f.a.Finish(ctx, old, f.binding, testID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"ok":true}`)}); err != nil {
t.Fatal("in-flight finish rejected", err)
}
if f.get(task.ID).State != pulse.TaskSucceeded || f.used() != 0 {
t.Fatal("task/permit changed by rotation")
}
final := change(credentialFinalizeAction, 3, 0)
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
t.Fatal(err)
}
if err := f.a.Authorize(ctx, old); fault.Code(err) != "UNAUTHENTICATED" {
t.Fatal("retired token accepted", err)
}
if err := f.a.Authorize(ctx, current); err != nil {
t.Fatal("current token rejected", err)
}
replay, err := ApplyCredentialOperation(ctx, db, rotation)
if err != nil || !replay.Deduplicated || !replay.PreviousValidUntil.Equal(originalUntil) {
t.Fatal("old receipt drifted", err)
}
if err := f.a.Authorize(ctx, old); err == nil {
t.Fatal("replay resurrected predecessor")
}
// 整张凭据撤销必须同时使两代令牌失效。
next := change(credentialRotateAction, 4, time.Hour)
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
t.Fatal(err)
}
newest, _ := auth.Parse(next.Token)
revoke := change(credentialRevokeAction, 5, 0)
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
t.Fatal(err)
}
for _, token := range []auth.Token{old, current, newest} {
if err := f.a.Authorize(ctx, token); fault.Code(err) != "UNAUTHENTICATED" {
t.Fatal("revocation missed a generation", err)
}
}
var audits string
if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('before',`+"`before`"+`,'after',`+"`after`"+`)) AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='credential'`, old.NamespaceID).Scan(&audits); err != nil {
t.Fatal(err)
}
if strings.Contains(audits, rotation.Token) || strings.Contains(audits, "secret_digest") {
t.Fatal("audit leaked secret")
}
}
func TestIntegrationCredentialOverlapDeadline(t *testing.T) {
ctx, db := overlapDB(t)
f := newExecutionFixture(t, ctx, db)
old := f.token
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
before, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
if err != nil {
t.Fatal(err)
}
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: 24 * time.Hour, Reason: "bounded grace"})
if err != nil {
t.Fatal(err)
}
out, err := ApplyCredentialOperation(ctx, db, op)
if err != nil || !out.PreviousValidUntil.Equal(*before.ExpiresAt) {
t.Fatal("grace extended original expiry", err)
}
current, _ := auth.Parse(op.Token)
// 新令牌过期不会改变独立的旧令牌截止;旧令牌也不能越过自己的截止。
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
if f.a.Authorize(ctx, old) != nil || fault.Code(f.a.Authorize(ctx, current)) != "UNAUTHENTICATED" {
t.Fatal("expiry of two generations conflated")
}
f.exec(`UPDATE orbit_credentials SET expires_at=NULL,previous_valid_until=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 MICROSECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
t.Fatal("expired old token remained usable")
}
// 过期的旧令牌不需定时清理才能失效;下一轮可以重用单一旧代槽。
next, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 2, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "next rotation"})
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
t.Fatal(err)
}
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
t.Fatal("wrong predecessor reused")
}
}
// 两个独立 HTTP/WS 节点共享真实 MySQL,验证旧长连接命令和跨节点重连。
func TestIntegrationCredentialOverlapWebSocket(t *testing.T) {
ctx, db := overlapDB(t)
f := newExecutionFixture(t, ctx, db)
serve := func(service *worker.Service) *httptest.Server {
hub := httpserver.NewWorkerHub(ctx, service)
server := httpserver.New("", nil, f.tasks)
httpserver.RegisterWorkers(server, hub)
ts := httptest.NewServer(server.Handler)
t.Cleanup(func() {
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := hub.Shutdown(shutdown); err != nil {
t.Error(err)
}
ts.Close()
})
return ts
}
a, b := serve(f.a), serve(f.b)
hello := f.hello
hello.InstanceID = testID(t)
hello.Capabilities = []worker.Capability{{Handler: "rotation.idle", HandlerVersion: "v1", PayloadVersion: "v1"}}
type frame struct {
Type string `json:"type"`
SessionID core.ID `json:"session_id"`
Error *fault.Error `json:"error"`
}
connect := func(url, token string) (*websocket.Conn, core.ID) {
t.Helper()
c, _, err := websocket.Dial(ctx, url+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + token}}, Subprotocols: []string{"pulse.worker.v1"}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.CloseNow() })
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.hello", "message_id": testID(t), "body": hello}); err != nil {
t.Fatal(err)
}
var out frame
if err := wsjson.Read(ctx, c, &out); err != nil || out.Type != "worker.welcome" {
t.Fatalf("WS welcome: %v, %s", err, out.Type)
}
return c, out.SessionID
}
capacity := func(c *websocket.Conn, session core.ID, seq int, want string) {
t.Helper()
body := worker.CapacityInput{Sequence: uint64(seq), Capacity: 0}
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.capacity", "message_id": testID(t), "session_id": session, "body": body}); err != nil {
t.Fatal(err)
}
var out frame
if err := wsjson.Read(ctx, c, &out); err != nil {
t.Fatal(err)
}
code := ""
if out.Error != nil {
code = out.Error.Code
}
if code != want {
t.Fatalf("WS auth: %s, want %s", code, want)
}
}
oldConn, session := connect(a.URL, f.resources.Token)
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "rolling switch"})
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, op); err != nil {
t.Fatal(err)
}
capacity(oldConn, session, 1, "")
oldConn.CloseNow()
reconnected, oldSession := connect(b.URL, f.resources.Token)
capacity(reconnected, oldSession, 1, "")
hello.InstanceID = testID(t)
current, newSession := connect(a.URL, op.Token)
capacity(current, newSession, 1, "")
final, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 2, Action: credentialFinalizeAction, Reason: "all services switched"})
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
t.Fatal(err)
}
capacity(reconnected, oldSession, 2, "UNAUTHENTICATED")
capacity(current, newSession, 2, "")
c, response, err := websocket.Dial(ctx, b.URL+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + f.resources.Token}}, Subprotocols: []string{"pulse.worker.v1"}})
if c != nil {
c.CloseNow()
}
if err == nil || response == nil || response.StatusCode != 401 {
t.Fatal("retired WS handshake accepted")
}
}