merge fix/token-backfill-null: legacy NULL token_plain backfill (V1.0.1)

This commit is contained in:
Muse
2026-10-23 00:21:04 +08:00
2 changed files with 64 additions and 3 deletions
+8 -3
View File
@@ -83,11 +83,16 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
// Item 19 backfill: the token has just passed the hash check, so for
// rows created before plaintext storage existed, adopt the presented
// token as the recoverable copy. The guarded update only fires while
// token_plain is empty — an authenticated request can never overwrite
// an already-stored token (only issuance writes a fresh one).
// token_plain is unset — an authenticated request can never overwrite
// an already-stored token (only issuance writes a fresh one). "Unset"
// must cover SQL NULL as well as '': AutoMigrate added the column to
// pre-existing rows as NULL, and NULL = '' is never true, so an
// equality-only guard silently matched zero legacy rows in
// production (the GORM-scanned value reads as "" either way, which is
// why the in-memory check above cannot distinguish them).
if agent.TokenPlain == "" {
if res := database.GetDB().Model(&model.AgentNode{}).
Where("id = ? AND token_plain = ''", agent.Id).
Where("id = ? AND (token_plain IS NULL OR token_plain = '')", agent.Id).
Update("token_plain", token); res.Error == nil && res.RowsAffected > 0 {
agent.TokenPlain = token
}
@@ -123,6 +123,62 @@ func TestAgentTokenBackfillOnAuthenticatedRequest(t *testing.T) {
}
}
// Regression for the production failure of the first V1.0.0 deployment:
// rows that existed before item 19 got token_plain from AutoMigrate as
// SQL NULL, not ”, and the original guard `token_plain = ”` never
// matches NULL — the backfill silently never fired on any legacy node.
// Seed the row exactly as production has it (raw SQL, explicit NULL)
// instead of through GORM, which would write ”.
func TestAgentTokenBackfillFromNullLegacyRow(t *testing.T) {
engine, node := setupBackfillTest(t)
const token = "legacy-token-backfill-00000000000000000004"
db := database.GetDB()
if err := db.Exec(
"INSERT INTO agent_nodes (token_hash, token_prefix, token_plain, name, node_id, last_seen, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, 0, 0, 0)",
hashAgentToken(token), token[:12], "backfill-node", node.Id,
).Error; err != nil {
t.Fatalf("seed NULL legacy row: %v", err)
}
var seeded model.AgentNode
if err := db.Where("token_hash = ?", hashAgentToken(token)).First(&seeded).Error; err != nil {
t.Fatalf("load seeded row: %v", err)
}
var nullRows int
if err := db.Raw("SELECT COUNT(*) FROM agent_nodes WHERE id = ? AND token_plain IS NULL", seeded.Id).Scan(&nullRows).Error; err != nil {
t.Fatalf("NULL check: %v", err)
}
if nullRows != 1 {
t.Fatalf("seeded row is not SQL NULL as intended (null rows = %d)", nullRows)
}
if code := heartbeatWith(engine, token); code != http.StatusOK {
t.Fatalf("heartbeat with valid token: status %d", code)
}
if row := reloadAgent(t, seeded.Id); row.TokenPlain != token {
t.Fatalf("token not backfilled from NULL legacy row: %q", row.TokenPlain)
}
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/install/"+strconv.Itoa(node.Id), nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("install endpoint: status %d", rec.Code)
}
var resp struct {
Success bool `json:"success"`
Obj struct {
Token string `json:"token"`
HasToken bool `json:"hasToken"`
} `json:"obj"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("install response not JSON: %v", err)
}
if !resp.Success || !resp.Obj.HasToken || resp.Obj.Token != token {
t.Fatalf("install endpoint did not return the backfilled token: %+v", resp.Obj)
}
}
func TestAgentTokenNoBackfillOnFailedAuth(t *testing.T) {
engine, node := setupBackfillTest(t)
const token = "legacy-token-backfill-00000000000000000002"