Files
heicode-mananger/heicode/middleware/device_auth_diagnostic_test.go
T
chenchenandClaude Opus 4.7 b615f3dc67 fix(devices): V2 device-binding hardening + Web Devices UI
Server-side bug fixes (zero client-impact):
- fix(devices): re-pair after revoke now reactivates the row instead of
  returning a stale "reused:true" response. Before this, a user who
  revoked a device in Web UI then re-launched the desktop app got
  HTTP 200 from /pair but every subsequent V2 request 401'd with
  ErrDeviceRevoked, leaving them locked out.
- feat(v2): V2 auth failures now carry X-Heicode-Server-Time and
  X-Heicode-Auth-Error response headers. Lets the desktop client
  distinguish clock drift (timestamp_drift) from revoke/signature
  failures and show actionable messages instead of "Token invalid".
- fix(devices): RenameUserDevice rejects whitespace-only names (400)
  and truncates by rune count instead of bytes, so multi-byte UTF-8
  names (Chinese / Japanese) don't get mangled at the 64-byte boundary.
- feat(devices): RevokeUserDevice writes a SysLog audit line with
  user_id / token_id / device_id / device_name / operator IP / reason.
  Symmetric with the existing "reactivated revoked device" log so
  admins can trace both transitions when investigating lockouts.
- fix(devices): GetUserDeviceBoundTokens sort uses
  CASE WHEN device_last_used_at = 0 THEN device_bound_at ELSE
  device_last_used_at END DESC so a freshly-paired device doesn't
  sink below older but actively-used machines in the Devices list.
  Portable across SQLite / MySQL / PostgreSQL.

Web UI (web/default):
- New /devices route + features/devices/ page with table, revoke
  AlertDialog, rename Dialog, greyed-out revoked rows, empty state.
- Sidebar "Personal" group now shows "Devices" between Models and
  Account security (Smartphone icon).
- i18n strings added to zh.json + en.json.

Tests:
- 8 new tests covering re-pair reactivation, rename validation
  edge cases, sort order, audit log shape, V2 error code mapping,
  and diagnostic header emission. Full controller / middleware /
  model suite remains green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:46:07 +08:00

108 lines
3.7 KiB
Go

package middleware
import (
"errors"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// V2 auth diagnostic helpers — covers the client-facing contract that
// every V2 401 carries both X-Heicode-Server-Time (so the client can
// detect clock drift) and X-Heicode-Auth-Error (so it can map the failure
// to a localized message instead of "Token invalid").
//
// These tests pin the public error-code strings; renaming any of them
// without coordinated client-side rollout will break drift detection on
// already-shipped desktop builds.
func TestV2AuthErrorCode_KnownSentinels(t *testing.T) {
cases := []struct {
err error
want string
}{
{ErrDeviceTimestampOutOfWindow, V2AuthErrorTimestampDrift},
{ErrDeviceNonceReplayed, V2AuthErrorNonceReplay},
{ErrDeviceRevoked, V2AuthErrorRevoked},
{ErrDeviceFingerprintMismatch, V2AuthErrorFingerprintMismatch},
{ErrDeviceSignatureInvalid, V2AuthErrorSignatureInvalid},
{ErrDeviceSignatureMissing, V2AuthErrorSignatureMissing},
{ErrV2DeviceNotFound, V2AuthErrorDeviceNotFound},
{ErrV2EphPubkeyMissing, V2AuthErrorEphPubkeyMissing},
{ErrV2EphPubkeyMalformed, V2AuthErrorEphPubkeyMalformed},
{ErrV2AEADDecryptFailed, V2AuthErrorBodyDecryptFailed},
{ErrV2BodyTooShort, V2AuthErrorBodyTooShort},
{ErrV2BodyReadFailed, V2AuthErrorBodyReadFailed},
{ErrDeviceSignatureBodyReadFail, V2AuthErrorBodyReadFailed},
{ErrV2KeyDeriveFailed, V2AuthErrorKeyDeriveFailed},
{ErrV2HelpersNotInitialized, V2AuthErrorServerNotReady},
}
for _, tc := range cases {
got := V2AuthErrorCode(tc.err)
if got != tc.want {
t.Errorf("V2AuthErrorCode(%v) = %q, want %q", tc.err, got, tc.want)
}
}
}
func TestV2AuthErrorCode_UnknownDefaultsToUnknown(t *testing.T) {
// Any error not in the sentinel list (e.g. a wrapped DB error,
// gorm.ErrRecordNotFound, network read) must map to "unknown"
// rather than panicking or leaking the Go error string to the
// client. The client treats "unknown" as a generic auth failure.
custom := errors.New("not a v2 sentinel")
if got := V2AuthErrorCode(custom); got != V2AuthErrorUnknown {
t.Fatalf("expected %q for unknown error, got %q", V2AuthErrorUnknown, got)
}
}
func TestSetV2AuthDiagnosticHeaders_SetsBothHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
before := time.Now().UnixMilli()
SetV2AuthDiagnosticHeaders(c, ErrDeviceTimestampOutOfWindow)
after := time.Now().UnixMilli()
got := rec.Header().Get(HeaderAuthError)
if got != V2AuthErrorTimestampDrift {
t.Errorf("X-Heicode-Auth-Error = %q, want %q", got, V2AuthErrorTimestampDrift)
}
tsStr := rec.Header().Get(HeaderServerTime)
if tsStr == "" {
t.Fatalf("X-Heicode-Server-Time missing")
}
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
t.Fatalf("X-Heicode-Server-Time %q not unix_ms: %v", tsStr, err)
}
if ts < before || ts > after {
t.Errorf("X-Heicode-Server-Time = %d, want in [%d, %d]", ts, before, after)
}
}
func TestSetV2AuthDiagnosticHeaders_UnknownErrorStillStampsTime(t *testing.T) {
// Even when the error is unknown, the server MUST still set the
// time header — the client uses it to detect "my clock is way off"
// even if it can't act on the error code itself.
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
SetV2AuthDiagnosticHeaders(c, errors.New("opaque"))
if got := rec.Header().Get(HeaderAuthError); got != V2AuthErrorUnknown {
t.Errorf("want %q, got %q", V2AuthErrorUnknown, got)
}
if rec.Header().Get(HeaderServerTime) == "" {
t.Fatalf("X-Heicode-Server-Time missing on unknown-error path")
}
}