Files
dingtalk-workspace-cli/internal/keychain/migration_darwin_test.go
修雨 bfd48b6a71 fix: preserve macOS auth across keychain mode changes (#597)
* fix: preserve auth across macOS keychain modes

* docs(auth): clarify per-profile recovery

* fix(auth): add safe macOS keychain migration

* ci: add native Windows auth coverage

* ci: scope Windows checks to auth paths

* fix(auth): address keychain review boundaries
2026-07-13 15:27:39 +08:00

178 lines
6.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build darwin
package keychain
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMigrateToFileDEKReencryptsSystemEntries(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
t.Setenv(DisableKeychainEnv, "")
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x42}, dekBytes), nil)
service := "test-migrate-system-to-file"
values := map[string]string{
AccountToken: "legacy-token",
AccountToken + ":corp-a": "profile-token",
}
if err := Set(service, "appsecret_demo", "app-secret"); err != nil {
t.Fatalf("Set(unrelated secret) error = %v", err)
}
unrelatedPath := filepath.Join(StorageDir(service), safeFileName("appsecret_demo"))
unrelatedCiphertext := []byte("unreadable unrelated ciphertext")
if err := os.WriteFile(unrelatedPath, unrelatedCiphertext, 0600); err != nil {
t.Fatalf("WriteFile(unrelated secret) error = %v", err)
}
for account, value := range values {
if err := Set(service, account, value); err != nil {
t.Fatalf("Set(%q) error = %v", account, err)
}
}
count, err := MigrateToFileDEK(service, true)
if err != nil {
t.Fatalf("MigrateToFileDEK(dry-run) error = %v", err)
}
if count != len(values) {
t.Fatalf("dry-run count = %d, want %d", count, len(values))
}
dekPath := filepath.Join(StorageDir(service), "dek")
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
t.Fatalf("dry-run created file DEK; stat error = %v", err)
}
count, err = MigrateToFileDEK(service, false)
if err != nil {
t.Fatalf("MigrateToFileDEK() error = %v", err)
}
if count != len(values) {
t.Fatalf("migration count = %d, want %d", count, len(values))
}
if info, err := os.Stat(dekPath); err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("file DEK stat = %v, %v; want mode 0600", info, err)
}
for _, disableKeychain := range []string{"1", ""} {
t.Setenv(DisableKeychainEnv, disableKeychain)
for account, want := range values {
got, err := Get(service, account)
if err != nil || got != want {
t.Fatalf("Get(%q) with %s=%q = %q, %v; want %q", account, DisableKeychainEnv, disableKeychain, got, err, want)
}
}
}
if count, err := MigrateToFileDEK(service, true); err != nil || count != len(values) {
t.Fatalf("repeat dry-run = %d, %v; want %d auth entries", count, err, len(values))
}
if got, err := os.ReadFile(unrelatedPath); err != nil || !bytes.Equal(got, unrelatedCiphertext) {
t.Fatalf("unrelated secret after migration = %q, %v; want byte-for-byte preserved", got, err)
}
}
func TestMigrateToFileDEKKeepsNewNonAuthSecretsOnSystemKeychain(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
t.Setenv(DisableKeychainEnv, "")
systemDEK := bytes.Repeat([]byte{0x52}, dekBytes)
stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-migrate-secret-isolation"
if err := Set(service, AccountToken, "system-token"); err != nil {
t.Fatalf("Set(auth token) error = %v", err)
}
if _, err := MigrateToFileDEK(service, false); err != nil {
t.Fatalf("MigrateToFileDEK() error = %v", err)
}
nonAuthEntries := map[string]string{
"client-secret:demo": "client-secret",
"app-token:demo": "app-token",
"appsecret:demo": "stored-secret",
}
for account, value := range nonAuthEntries {
if err := Set(service, account, value); err != nil {
t.Fatalf("Set(%q) after migration error = %v", account, err)
}
ciphertext, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", account, err)
}
got, err := decryptData(ciphertext, systemDEK)
if err != nil || got != value {
t.Fatalf("system Keychain DEK decrypt %q = %q, %v; want %q", account, got, err, value)
}
}
t.Setenv(DisableKeychainEnv, "1")
if got, err := Get(service, AccountToken); err != nil || got != "system-token" {
t.Fatalf("file-DEK Get(auth token) = %q, %v; want migrated token", got, err)
}
for account := range nonAuthEntries {
if got, err := Get(service, account); !IsCiphertextKeyMismatch(err) {
t.Fatalf("file-DEK Get(%q) = %q, %v; want ciphertext key mismatch", account, got, err)
}
}
}
func TestMigrateToFileDEKAbortsBeforeWritingWhenAnyEntryIsUnreadable(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
t.Setenv(DisableKeychainEnv, "")
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x24}, dekBytes), nil)
service := "test-migrate-preflight"
if err := Set(service, AccountToken, "preserve-me"); err != nil {
t.Fatalf("Set() error = %v", err)
}
goodPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
goodBefore, err := os.ReadFile(goodPath)
if err != nil {
t.Fatalf("ReadFile(good entry) error = %v", err)
}
badPath := filepath.Join(StorageDir(service), safeFileName(AccountToken+":bad"))
if err := os.WriteFile(badPath, []byte("corrupt ciphertext"), 0600); err != nil {
t.Fatalf("WriteFile(bad entry) error = %v", err)
}
if _, err := MigrateToFileDEK(service, false); err == nil || !strings.Contains(err.Error(), "validate keychain entry") {
t.Fatalf("MigrateToFileDEK() error = %v, want preflight failure", err)
}
goodAfter, err := os.ReadFile(goodPath)
if err != nil {
t.Fatalf("ReadFile(good entry after failure) error = %v", err)
}
if !bytes.Equal(goodAfter, goodBefore) {
t.Fatal("failed migration modified a readable entry")
}
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
t.Fatalf("failed migration created file DEK; stat error = %v", err)
}
}
func TestMigrateToFileDEKRequiresSystemKeychainMode(t *testing.T) {
t.Setenv(StorageDirEnv, t.TempDir())
t.Setenv(DisableKeychainEnv, "1")
if _, err := MigrateToFileDEK("test-migrate-mode", true); err == nil || !strings.Contains(err.Error(), DisableKeychainEnv) {
t.Fatalf("MigrateToFileDEK() error = %v, want mode guidance", err)
}
}