timezone_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. package timezone
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "os"
  7. "os/exec"
  8. "path/filepath"
  9. "runtime"
  10. "strings"
  11. "testing"
  12. )
  13. // setupWintzJSON creates the ./system/time/wintz.json fixture that
  14. // ConvertWinTZtoLinuxTZ reads, and returns a cleanup function.
  15. // The working directory during tests is the package directory, so we
  16. // create the relative path from there.
  17. func setupWintzJSON(t *testing.T, content string) func() {
  18. t.Helper()
  19. if err := os.MkdirAll("./system/time", 0755); err != nil {
  20. t.Fatalf("failed to create system/time dir: %v", err)
  21. }
  22. if err := os.WriteFile("./system/time/wintz.json", []byte(content), 0644); err != nil {
  23. t.Fatalf("failed to write wintz.json: %v", err)
  24. }
  25. return func() {
  26. os.RemoveAll("./system")
  27. }
  28. }
  29. const sampleWintzJSON = `{
  30. "supplementalData": {
  31. "version": {"_number": "1"},
  32. "windowsZones": {
  33. "mapTimezones": {
  34. "_otherVersion": "1",
  35. "_typeVersion": "1",
  36. "mapZone": [
  37. {"_other": "Pacific Standard Time", "_territory": "US", "_type": "America/Los_Angeles"},
  38. {"_other": "Eastern Standard Time", "_territory": "US", "_type": "America/New_York America/Detroit"},
  39. {"_other": "UTC", "_territory": "001","_type": "Etc/GMT"}
  40. ]
  41. }
  42. }
  43. }
  44. }`
  45. // --- ConvertWinTZtoLinuxTZ ---
  46. // ConvertWinTZtoLinuxTZ reads from ./system/time/wintz.json.
  47. // When that file is missing (as in a test environment), it returns "".
  48. func TestConvertWinTZtoLinuxTZ_MissingFile(t *testing.T) {
  49. // Ensure no wintz.json exists for this sub-test
  50. os.RemoveAll("./system")
  51. // When wintz.json doesn't exist the function returns ""
  52. result := ConvertWinTZtoLinuxTZ("Pacific Standard Time")
  53. // Accept either "" (file missing) or a valid IANA timezone
  54. if result != "" {
  55. // If a result was returned it should look like a valid IANA timezone
  56. if !strings.Contains(result, "/") && result != "UTC" {
  57. t.Errorf("unexpected result from ConvertWinTZtoLinuxTZ: %q", result)
  58. }
  59. }
  60. }
  61. func TestConvertWinTZtoLinuxTZ_UnknownTZ(t *testing.T) {
  62. cleanup := setupWintzJSON(t, sampleWintzJSON)
  63. defer cleanup()
  64. // An unknown Windows TZ should return ""
  65. result := ConvertWinTZtoLinuxTZ("This Is Not A Real Timezone")
  66. if result != "" {
  67. t.Errorf("expected empty string for unknown TZ, got %q", result)
  68. }
  69. }
  70. func TestConvertWinTZtoLinuxTZ_EmptyInput(t *testing.T) {
  71. cleanup := setupWintzJSON(t, sampleWintzJSON)
  72. defer cleanup()
  73. result := ConvertWinTZtoLinuxTZ("")
  74. // Empty string cannot match any zone, so result must be ""
  75. if result != "" {
  76. t.Errorf("expected empty string for empty input, got %q", result)
  77. }
  78. }
  79. func TestConvertWinTZtoLinuxTZ_KnownMapping_Pacific(t *testing.T) {
  80. cleanup := setupWintzJSON(t, sampleWintzJSON)
  81. defer cleanup()
  82. result := ConvertWinTZtoLinuxTZ("Pacific Standard Time")
  83. if result != "America/Los_Angeles" {
  84. t.Errorf("expected 'America/Los_Angeles', got %q", result)
  85. }
  86. }
  87. func TestConvertWinTZtoLinuxTZ_KnownMapping_Eastern(t *testing.T) {
  88. cleanup := setupWintzJSON(t, sampleWintzJSON)
  89. defer cleanup()
  90. // Eastern Standard Time maps to "America/New_York America/Detroit";
  91. // ConvertWinTZtoLinuxTZ takes the first space-split value.
  92. result := ConvertWinTZtoLinuxTZ("Eastern Standard Time")
  93. if result != "America/New_York" {
  94. t.Errorf("expected 'America/New_York', got %q", result)
  95. }
  96. }
  97. func TestConvertWinTZtoLinuxTZ_KnownMapping_UTC(t *testing.T) {
  98. cleanup := setupWintzJSON(t, sampleWintzJSON)
  99. defer cleanup()
  100. result := ConvertWinTZtoLinuxTZ("UTC")
  101. if result != "Etc/GMT" {
  102. t.Errorf("expected 'Etc/GMT', got %q", result)
  103. }
  104. }
  105. // showTimeSafe calls ShowTime and recovers from any panic (which can occur
  106. // on non-systemd Linux hosts where timedatectl panics due to missing '=' in output).
  107. // It returns false if a panic occurred, true otherwise.
  108. func showTimeSafe(w http.ResponseWriter, r *http.Request) (panicked bool) {
  109. defer func() {
  110. if rec := recover(); rec != nil {
  111. panicked = true
  112. }
  113. }()
  114. ShowTime(w, r)
  115. return false
  116. }
  117. // --- ShowTime ---
  118. func TestShowTime_ResponseCode(t *testing.T) {
  119. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  120. rr := httptest.NewRecorder()
  121. if panicked := showTimeSafe(rr, req); panicked {
  122. t.Skip("ShowTime panicked (likely non-systemd environment without timedatectl); skipping")
  123. }
  124. if rr.Code != http.StatusOK {
  125. t.Errorf("expected status 200, got %d", rr.Code)
  126. }
  127. }
  128. func TestShowTime_ResponseIsJSON(t *testing.T) {
  129. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  130. rr := httptest.NewRecorder()
  131. if panicked := showTimeSafe(rr, req); panicked {
  132. t.Skip("ShowTime panicked (likely non-systemd environment without timedatectl); skipping")
  133. }
  134. body := rr.Body.String()
  135. if !json.Valid([]byte(body)) {
  136. t.Errorf("expected valid JSON response, got: %q", body)
  137. }
  138. }
  139. func TestShowTime_HasTimeField(t *testing.T) {
  140. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  141. rr := httptest.NewRecorder()
  142. if panicked := showTimeSafe(rr, req); panicked {
  143. t.Skip("ShowTime panicked (likely non-systemd environment without timedatectl); skipping")
  144. }
  145. body := rr.Body.String()
  146. var result map[string]interface{}
  147. if err := json.Unmarshal([]byte(body), &result); err != nil {
  148. t.Fatalf("failed to unmarshal response: %v", err)
  149. }
  150. if _, ok := result["time"]; !ok {
  151. t.Errorf("expected 'time' field in response, got: %v", result)
  152. }
  153. }
  154. func TestShowTime_HasTimezoneField(t *testing.T) {
  155. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  156. rr := httptest.NewRecorder()
  157. if panicked := showTimeSafe(rr, req); panicked {
  158. t.Skip("ShowTime panicked (likely non-systemd environment without timedatectl); skipping")
  159. }
  160. body := rr.Body.String()
  161. var result map[string]interface{}
  162. if err := json.Unmarshal([]byte(body), &result); err != nil {
  163. t.Fatalf("failed to unmarshal response: %v", err)
  164. }
  165. if _, ok := result["timezone"]; !ok {
  166. t.Errorf("expected 'timezone' field in response, got: %v", result)
  167. }
  168. }
  169. func TestShowTime_TimeFieldIsString(t *testing.T) {
  170. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  171. rr := httptest.NewRecorder()
  172. if panicked := showTimeSafe(rr, req); panicked {
  173. t.Skip("ShowTime panicked (likely non-systemd environment without timedatectl); skipping")
  174. }
  175. body := rr.Body.String()
  176. var result map[string]interface{}
  177. if err := json.Unmarshal([]byte(body), &result); err != nil {
  178. t.Fatalf("failed to unmarshal response: %v", err)
  179. }
  180. timeVal, ok := result["time"].(string)
  181. if !ok {
  182. t.Fatalf("expected 'time' to be a string, got %T", result["time"])
  183. }
  184. if timeVal == "" {
  185. t.Error("expected non-empty 'time' field")
  186. }
  187. }
  188. func TestShowTime_TimeIsRFC3339(t *testing.T) {
  189. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  190. rr := httptest.NewRecorder()
  191. if panicked := showTimeSafe(rr, req); panicked {
  192. t.Skip("ShowTime panicked (likely non-systemd environment without timedatectl); skipping")
  193. }
  194. body := rr.Body.String()
  195. var result returnFormat
  196. if err := json.Unmarshal([]byte(body), &result); err != nil {
  197. t.Fatalf("failed to unmarshal response: %v", err)
  198. }
  199. // RFC3339 times contain 'T' separator and timezone offset
  200. if !strings.Contains(result.Time, "T") {
  201. t.Errorf("expected RFC3339 time format with 'T' separator, got %q", result.Time)
  202. }
  203. }
  204. // --- WindowsTimeZoneStruct (struct validation) ---
  205. func TestWindowsTimeZoneStruct_Unmarshal(t *testing.T) {
  206. // Verify the struct can be correctly unmarshalled from a minimal JSON
  207. raw := `{
  208. "supplementalData": {
  209. "version": {"_number": "1"},
  210. "windowsZones": {
  211. "mapTimezones": {
  212. "mapZone": [
  213. {"_other": "Pacific Standard Time", "_territory": "US", "_type": "America/Los_Angeles"}
  214. ],
  215. "_otherVersion": "1",
  216. "_typeVersion": "1"
  217. }
  218. }
  219. }
  220. }`
  221. var tzData WindowsTimeZoneStruct
  222. if err := json.Unmarshal([]byte(raw), &tzData); err != nil {
  223. t.Fatalf("failed to unmarshal WindowsTimeZoneStruct: %v", err)
  224. }
  225. zones := tzData.SupplementalData.WindowsZones.MapTimezones.MapZone
  226. if len(zones) != 1 {
  227. t.Fatalf("expected 1 zone entry, got %d", len(zones))
  228. }
  229. if zones[0].Other != "Pacific Standard Time" {
  230. t.Errorf("expected 'Pacific Standard Time', got %q", zones[0].Other)
  231. }
  232. if zones[0].Type != "America/Los_Angeles" {
  233. t.Errorf("expected 'America/Los_Angeles', got %q", zones[0].Type)
  234. }
  235. }
  236. // --- returnFormat (struct validation) ---
  237. func TestReturnFormat_Unmarshal(t *testing.T) {
  238. raw := `{"time":"2024-01-15T10:30:00Z","timezone":"America/New_York"}`
  239. var rf returnFormat
  240. if err := json.Unmarshal([]byte(raw), &rf); err != nil {
  241. t.Fatalf("failed to unmarshal returnFormat: %v", err)
  242. }
  243. if rf.Time != "2024-01-15T10:30:00Z" {
  244. t.Errorf("unexpected Time: %q", rf.Time)
  245. }
  246. if rf.Timezone != "America/New_York" {
  247. t.Errorf("unexpected Timezone: %q", rf.Timezone)
  248. }
  249. }
  250. func TestReturnFormat_Marshal(t *testing.T) {
  251. rf := returnFormat{
  252. Time: "2024-01-15T10:30:00Z",
  253. Timezone: "Europe/London",
  254. }
  255. data, err := json.Marshal(rf)
  256. if err != nil {
  257. t.Fatalf("failed to marshal returnFormat: %v", err)
  258. }
  259. if !strings.Contains(string(data), `"time"`) {
  260. t.Errorf("expected 'time' key in JSON output, got: %s", data)
  261. }
  262. if !strings.Contains(string(data), `"timezone"`) {
  263. t.Errorf("expected 'timezone' key in JSON output, got: %s", data)
  264. }
  265. }
  266. // setupFakeTimedatectl creates a temporary directory with a fake timedatectl
  267. // binary that outputs "Timezone=America/Los_Angeles\n", injects it into PATH,
  268. // and returns a cleanup function. Only used on Linux.
  269. func setupFakeTimedatectl(t *testing.T) (cleanup func()) {
  270. t.Helper()
  271. if runtime.GOOS != "linux" {
  272. return func() {}
  273. }
  274. dir := t.TempDir()
  275. fakeBin := filepath.Join(dir, "timedatectl")
  276. script := "#!/bin/sh\necho 'Timezone=America/Los_Angeles'\n"
  277. if err := os.WriteFile(fakeBin, []byte(script), 0755); err != nil {
  278. t.Fatalf("failed to write fake timedatectl: %v", err)
  279. }
  280. oldPath := os.Getenv("PATH")
  281. os.Setenv("PATH", dir+string(os.PathListSeparator)+oldPath)
  282. return func() {
  283. os.Setenv("PATH", oldPath)
  284. }
  285. }
  286. // verifyFakeTimedatectl checks that the fake binary is picked up by exec.LookPath.
  287. func verifyFakeTimedatectl(t *testing.T) bool {
  288. t.Helper()
  289. p, err := exec.LookPath("timedatectl")
  290. if err != nil {
  291. return false
  292. }
  293. cmd := exec.Command(p, "show", "-p", "Timezone")
  294. out, err := cmd.CombinedOutput()
  295. if err != nil {
  296. return false
  297. }
  298. return strings.Contains(string(out), "=")
  299. }
  300. // TestShowTime_WithFakeTimedatectl exercises ShowTime on Linux using a fake
  301. // timedatectl that produces valid output, covering the returnFormat creation,
  302. // json.Marshal and utils.SendJSONResponse lines.
  303. func TestShowTime_WithFakeTimedatectl(t *testing.T) {
  304. if runtime.GOOS != "linux" {
  305. t.Skip("Linux-only test")
  306. }
  307. cleanup := setupFakeTimedatectl(t)
  308. defer cleanup()
  309. if !verifyFakeTimedatectl(t) {
  310. t.Skip("fake timedatectl not reachable in PATH")
  311. }
  312. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  313. rr := httptest.NewRecorder()
  314. ShowTime(rr, req)
  315. if rr.Code != http.StatusOK {
  316. t.Errorf("expected status 200, got %d", rr.Code)
  317. }
  318. body := rr.Body.String()
  319. if !json.Valid([]byte(body)) {
  320. t.Errorf("expected valid JSON, got: %q", body)
  321. }
  322. }
  323. // TestShowTime_ResponseFields_WithFake verifies the JSON fields when
  324. // timedatectl output is valid.
  325. func TestShowTime_ResponseFields_WithFake(t *testing.T) {
  326. if runtime.GOOS != "linux" {
  327. t.Skip("Linux-only test")
  328. }
  329. cleanup := setupFakeTimedatectl(t)
  330. defer cleanup()
  331. if !verifyFakeTimedatectl(t) {
  332. t.Skip("fake timedatectl not reachable in PATH")
  333. }
  334. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  335. rr := httptest.NewRecorder()
  336. ShowTime(rr, req)
  337. body := rr.Body.String()
  338. var result map[string]interface{}
  339. if err := json.Unmarshal([]byte(body), &result); err != nil {
  340. t.Fatalf("failed to unmarshal response: %v", err)
  341. }
  342. if _, ok := result["time"]; !ok {
  343. t.Error("expected 'time' field in response")
  344. }
  345. if _, ok := result["timezone"]; !ok {
  346. t.Error("expected 'timezone' field in response")
  347. }
  348. }
  349. // TestShowTime_TimezoneValue_WithFake confirms that the timezone value comes
  350. // from the fake timedatectl output.
  351. func TestShowTime_TimezoneValue_WithFake(t *testing.T) {
  352. if runtime.GOOS != "linux" {
  353. t.Skip("Linux-only test")
  354. }
  355. cleanup := setupFakeTimedatectl(t)
  356. defer cleanup()
  357. if !verifyFakeTimedatectl(t) {
  358. t.Skip("fake timedatectl not reachable in PATH")
  359. }
  360. req := httptest.NewRequest(http.MethodGet, "/time/show", nil)
  361. rr := httptest.NewRecorder()
  362. ShowTime(rr, req)
  363. body := rr.Body.String()
  364. var rf returnFormat
  365. if err := json.Unmarshal([]byte(body), &rf); err != nil {
  366. t.Fatalf("unmarshal: %v", err)
  367. }
  368. // The fake timedatectl outputs "Timezone=America/Los_Angeles\n"
  369. // After SplitN("Timezone=America/Los_Angeles\n","=",2)[1] we get "America/Los_Angeles\n"
  370. if !strings.Contains(rf.Timezone, "America/Los_Angeles") {
  371. t.Errorf("expected 'America/Los_Angeles' in timezone, got %q", rf.Timezone)
  372. }
  373. }