Files
story/internal/onboarding/lifecycle_test.go
T

143 lines
6.1 KiB
Go
Raw Normal View History

package onboarding
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"github.com/google/uuid"
"testing"
"time"
)
func TestIntegrationCredentialRotationAndReboot(t *testing.T) {
f := newFixture(t)
f.pending()
rotation := RotationInput{Reason: "test rotation", ExpectedVersion: 1}
req := uuid.NewString()
rotated, err := f.s.Rotate(f.ctx, f.p, f.tenantPublic, f.d.PublicID, req, req, requestJSON(rotation), rotation)
must(t, err)
_, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
if err == nil {
t.Fatal("pending key became active")
}
receiver, err := rsa.GenerateKey(rand.Reader, 2048)
must(t, err)
pub, err := x509.MarshalPKIXPublicKey(&receiver.PublicKey)
must(t, err)
job := rotated["enrollment_job_id"].(string)
input := DeliveryInput{JobID: job, ReceiverKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}))}
first, err := f.s.DeliverCredential(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), input)
must(t, err)
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_enrollment_job SET delivery_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, job)
must(t, err)
renewed, err := f.s.DeliverCredential(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), input)
must(t, err)
if first["ciphertext_b64"] == renewed["ciphertext_b64"] {
t.Fatal("expired envelope reused")
}
release := ReleaseInput{JobID: job, ExpectedVersion: 2, Evidence: "fixture readback"}
_, err = f.s.ReleaseInventory(f.ctx, f.p, f.tenantPublic, f.d.PublicID, uuid.NewString(), release)
must(t, err)
_, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
if err == nil {
t.Fatal("old key still active")
}
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
must(t, err)
boot := uuid.NewString()
challenge, err := f.s.TimeChallenge(f.ctx, TimeChallengeInput{DeviceID: f.d.FactoryID, KeyVersion: 2, BootID: boot, ClientNonce: randomToken(16)})
must(t, err)
raw, err := base64.RawURLEncoding.DecodeString(challenge["payload_b64"].(string))
must(t, err)
var packet map[string]any
must(t, json.Unmarshal(raw, &packet))
reg := RegisterInput{ChallengeID: packet["challenge_id"].(string), BootID: boot, Model: f.d.Model, HardwareRevision: f.d.HardwareRevision, FirmwareVersion: "1.2", SDKVersion: "1.0"}
_, err = f.s.Register(f.ctx, f.d, uuid.NewString(), requestJSON(reg), reg)
must(t, err)
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 2)
must(t, err)
_, err = f.s.Confirm(f.ctx, f.d, uuid.NewString(), requestJSON(f.confirm), f.confirm)
if err == nil {
t.Fatal("pre-reboot confirmation accepted")
}
}
func TestIntegrationRegistrationSurvivesRebindAndRejectsOldResult(t *testing.T) {
f := newFixture(t)
f.bound()
j := f.registerSDK()
var status string
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
must(t, err)
current, err := f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(t, err)
late := ResultInput{ActivationID: j.ID, AttemptID: j.AttemptID, Stage: "SDK_REGISTER", Outcome: "SUCCESS_OBSERVED"}
_, err = f.s.ActivationResult(f.ctx, current, uuid.NewString(), requestJSON(late), late)
if err == nil {
t.Fatal("old binding result was accepted")
}
f.d, err = f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
must(t, err)
setupIn := SetupInput{BootID: f.d.BootID, LocalSetupID: uuid.NewString(), Transport: "LOCAL_WEB"}
setup, err := f.s.Setup(f.ctx, f.d, uuid.NewString(), requestJSON(setupIn), setupIn)
must(t, err)
f.setup = setup["setup_id"].(string)
verifier := randomToken(32)
sum := sha256.Sum256([]byte(verifier))
handoffIn := HandoffInput{SetupID: f.setup, LocalID: setupIn.LocalSetupID, Challenge: encode64(sum[:])}
handoff, err := f.s.Handoff(f.ctx, f.d, uuid.NewString(), requestJSON(handoffIn), handoffIn)
must(t, err)
browser, err := f.s.Exchange(f.ctx, ExchangeInput{Token: handoff["handoff_token"].(string), Verifier: verifier})
must(t, err)
f.web, err = f.s.webSession(f.ctx, f.s.pool, digest([]byte(browser.Token)))
must(t, err)
browser, err = f.s.AuthLink(f.ctx, f.web, f.user)
must(t, err)
f.web, err = f.s.webSession(f.ctx, f.s.pool, digest([]byte(browser.Token)))
must(t, err)
f.bound()
must(t, f.s.ProcessActivation(f.ctx, f.event()))
if f.registrar.calls.Load() != 0 {
t.Fatal("rebind repeated supplier registration")
}
must(t, f.s.pool.QueryRow(f.ctx, `SELECT status FROM device_activation_job WHERE device_id=$1::uuid`, f.d.ID).Scan(&status))
if status != "REGISTERED_OBSERVED" {
t.Fatal("rebind did not resume activation")
}
}
func TestIntegrationCloseRequiresDurableACKAndGrace(t *testing.T) {
f := newFixture(t)
f.bound()
poll, err := f.s.Poll(f.ctx, f.d, PollInput{SetupID: f.setup, BootID: f.d.BootID})
must(t, err)
st := poll["state"].(State)
ack := StateAckInput{Revision: st.Revision, Generation: st.Generation, Hash: st.Hash, LocalGeneration: 1}
_, err = f.s.StateAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
var saved time.Time
must(t, f.s.pool.QueryRow(f.ctx, `SELECT accepted_at FROM device_state_ack WHERE device_id=$1::uuid`, f.d.ID).Scan(&saved))
_, err = f.s.StateAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack)
must(t, err)
var again time.Time
must(t, f.s.pool.QueryRow(f.ctx, `SELECT accepted_at FROM device_state_ack WHERE device_id=$1::uuid`, f.d.ID).Scan(&again))
if !again.Equal(saved) {
t.Fatal("ACK retry extended close grace")
}
closeIn := CloseInput{SetupID: f.setup, Reason: "SUCCESS", Revision: st.Revision}
_, err = f.s.Close(f.ctx, f.d, uuid.NewString(), requestJSON(closeIn), closeIn)
if err == nil {
t.Fatal("closed before 30 second grace")
}
var binding string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID).Scan(&binding))
webAck := WebAckInput{BindingID: binding, Generation: st.Generation, Revision: st.Revision}
_, err = f.s.WebAck(f.ctx, f.web, uuid.NewString(), requestJSON(webAck), webAck)
must(t, err)
f.s.now = func() time.Time { return time.Now().Add(4 * time.Second) }
_, err = f.s.Close(f.ctx, f.d, uuid.NewString(), requestJSON(closeIn), closeIn)
must(t, err)
}