master
cpp 888 lines 46.4 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 WSLCCLIArgumentUnitTests.cpp
8
9 Abstract:
10
11 This file contains unit tests for WSLC CLI argument parsing and validation.
12
13 --*/
14
15 #include "precomp.h"
16 #include "windows/Common.h"
17 #include "WSLCCLITestHelpers.h"
18
19 #include "Argument.h"
20 #include "ArgMap.h"
21 #include "ArgumentValidation.h"
22 #include "ImageService.h"
23 #include "JsonUtils.h"
24 #include "Exceptions.h"
25 #include <chrono>
26 #include <wslc.h>
27
28 using namespace wsl::windows::wslc;
29 using namespace wsl::windows::wslc::argument;
30
31 using namespace WSLCTestHelpers;
32 using namespace WEX::Logging;
33 using namespace WEX::Common;
34 using namespace WEX::TestExecution;
35
36 namespace WSLCCLIArgumentUnitTests {
37 namespace mount = wsl::windows::common::mount;
38
39 using RawArgMapBase = EnumBasedVariantMap<ArgType, wsl::windows::wslc::argument::details::ArgDataMapping, &ArgMapInvalidateValidatedCache>;
40
41 static_assert(!std::is_convertible_v<ArgMap*, RawArgMapBase*>);
42 static_assert(!std::is_copy_assignable_v<ArgMap>);
43 static_assert(!std::is_move_assignable_v<ArgMap>);
44
45 class WSLCCLIArgumentUnitTests
46 {
47 WSLC_TEST_CLASS(WSLCCLIArgumentUnitTests)
48
49 TEST_CLASS_SETUP(TestClassSetup)
50 {
51 // Add any necessary setup for argument tests
52 return true;
53 }
54
55 TEST_CLASS_CLEANUP(TestClassCleanup)
56 {
57 // Add any necessary cleanup for argument tests
58 return true;
59 }
60
61 TEST_METHOD(ArgumentException_OptionalArgumentHelp)
62 {
63 const ArgumentException withoutArgument{L"error"};
64 VERIFY_IS_TRUE(withoutArgument.Arguments().empty());
65
66 const auto argument = Argument::Create(ArgType::Verbose);
67 const ArgumentException withArgument{L"error", argument};
68 VERIFY_ARE_EQUAL(1u, withArgument.Arguments().size());
69 VERIFY_ARE_EQUAL(ArgType::Verbose, withArgument.Arguments().front().Type());
70 }
71
72 TEST_METHOD(ArgumentCreate_DefaultsAndOverrides)
73 {
74 const auto defaults = Argument::Create(ArgType::Quiet);
75 VERIFY_ARE_EQUAL(std::wstring{L"quiet"}, defaults.Name());
76 VERIFY_ARE_EQUAL(std::wstring{L"q"}, defaults.Alias());
77
78 const auto noAlias = Argument::Create(ArgType::Quiet, {.Alias = NO_ALIAS});
79 VERIFY_IS_TRUE(noAlias.Alias().empty());
80 VERIFY_ARE_EQUAL(defaults.Description(), noAlias.Description());
81
82 const auto overrides = Argument::Create(
83 ArgType::Filter, {.Name = L"where", .Alias = L"x", .Required = true, .Limit = Limit::Unlimited, .Desc = L"Custom description"});
84 VERIFY_ARE_EQUAL(std::wstring{L"where"}, overrides.Name());
85 VERIFY_ARE_EQUAL(std::wstring{L"x"}, overrides.Alias());
86 VERIFY_IS_TRUE(overrides.Required());
87 VERIFY_ARE_EQUAL(Limit::Unlimited, overrides.Limit());
88 VERIFY_ARE_EQUAL(std::wstring{L"Custom description"}, overrides.Description());
89 }
90
91 // Test: Verify Argument::Create() successfully creates arguments for all ArgType enum values
92 TEST_METHOD(ArgumentCreate_AllArguments)
93 {
94 // ArgMap is the container for processed args.
95 ArgMap args;
96
97 // Iterate through all ArgType enum values except Max
98 auto allArgTypes = std::vector<ArgType>{};
99 for (int i = 0; i < static_cast<int>(ArgType::Max); ++i)
100 {
101 ArgType argType = static_cast<ArgType>(i);
102
103 // Create argument using Create
104 Argument arg = Argument::Create(argType);
105
106 // Verify the argument was created successfully by checking its type matches
107 VERIFY_ARE_EQUAL(static_cast<int>(arg.Type()), i);
108
109 // Verify the argument has basic properties set
110 // (Name should not be empty for valid argument types)
111 VERIFY_IS_FALSE(arg.Name().empty());
112 LogComment(L"Verified Argument::Create() creates argument with name: " + arg.Name());
113
114 // Add the argument to the ArgMap with a test value based on its type.
115 VERIFY_IS_FALSE(args.Contains(argType));
116 switch (arg.Kind())
117 {
118 case Kind::Value:
119 case Kind::Positional:
120 args.Add(argType, std::wstring(L"test"));
121 break;
122 case Kind::Forward:
123 args.Add(argType, std::vector<std::wstring>{L"forward1", L"forward2"});
124 break;
125 case Kind::Flag:
126 args.Add(argType, true);
127 break;
128 default:
129 VERIFY_FAIL(L"Unhandled ValueType in test");
130 }
131
132 allArgTypes.push_back(argType);
133 VERIFY_IS_TRUE(args.Contains(argType));
134 }
135
136 // We do not have a runtime Get for argument values, so we will instead use the keys
137 // in the argmap. The fact that the keys exist and can be used to retrieve values
138 // verifies that Argument::Create() created arguments that are compatible with ArgMap.
139 // Verify all created argument types are in the ArgMap keys
140 auto argMapKeys = args.GetKeys();
141 VERIFY_ARE_EQUAL(argMapKeys.size(), allArgTypes.size());
142 for (const auto& argType : allArgTypes)
143 {
144 VERIFY_IS_TRUE(std::find(argMapKeys.begin(), argMapKeys.end(), argType) != argMapKeys.end());
145 }
146 }
147
148 // Test: Verify Argument::Create() successfully creates arguments for all ArgType enum values
149 TEST_METHOD(ArgumentValidation_ValueValidation)
150 {
151 // Verify integer conversion for supported types.
152 auto longlong = validation::GetIntegerFromString<LONGLONG>(L"1234567890123");
153 VERIFY_ARE_EQUAL(longlong, 1234567890123LL);
154 VERIFY_THROWS(validation::GetIntegerFromString<LONGLONG>(L"abc"), ArgumentException); // Not a number
155 VERIFY_THROWS(validation::GetIntegerFromString<LONGLONG>(L"-92233720369999854775808"), ArgumentException); // Out of range
156 VERIFY_NO_THROW(validation::ValidateIntegerFromString<LONGLONG>({L"1234", L"-1234567890123"}, L"testArg"));
157 VERIFY_THROWS(validation::ValidateIntegerFromString<LONGLONG>({L"1234", L"-92233720369999854775808"}, L"testArg"), ArgumentException);
158
159 // Verify --tail validation rejects 0 (mirrors ArgType::Tail validation)
160 VERIFY_THROWS(validation::ValidateIntegerFromString<ULONGLONG>({L"0"}, L"tail", [](auto value) { return value != 0; }), ArgumentException);
161 VERIFY_NO_THROW(validation::ValidateIntegerFromString<ULONGLONG>({L"10"}, L"tail", [](auto value) { return value != 0; }));
162 VERIFY_NO_THROW(validation::ValidateIntegerFromString<ULONGLONG>({L"1"}, L"tail", [](auto value) { return value != 0; }));
163
164 // Verify WSLCSignal conversion
165 auto validSignal = validation::GetWSLCSignalFromString(L"SIGTERM");
166 VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM);
167 validSignal = validation::GetWSLCSignalFromString(L"TERM"); // No prefix
168 VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM);
169 validSignal = validation::GetWSLCSignalFromString(L"sIgTerm"); // Case-insensitive
170 VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM);
171 validSignal = validation::GetWSLCSignalFromString(L"term"); // Case-insensitive no prefix
172 VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM);
173 VERIFY_THROWS(validation::GetWSLCSignalFromString(L"INVALID_SIGNAL"), ArgumentException);
174 validSignal = validation::GetWSLCSignalFromString(L"15"); // SIGTERM is 15
175 VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM);
176 VERIFY_THROWS(validation::GetWSLCSignalFromString(L"999"), ArgumentException); // Out of range
177 VERIFY_NO_THROW(validation::ValidateWSLCSignalFromString({L"HUP", L"9", L"SIGKILL", L"stop"}, L"signalArg"));
178 VERIFY_THROWS(validation::ValidateWSLCSignalFromString({L"SIGHUP", L"999"}, L"signalArg"), ArgumentException); // 999 is out of range
179
180 // Verify format type
181 auto format = validation::GetFormatTypeFromString(L"json");
182 VERIFY_ARE_EQUAL(format, FormatType::Json);
183 format = validation::GetFormatTypeFromString(L"table");
184 VERIFY_ARE_EQUAL(format, FormatType::Table);
185 VERIFY_THROWS(validation::GetFormatTypeFromString(L"xml"), ArgumentException);
186 VERIFY_NO_THROW(validation::ValidateFormatTypeFromString({L"json", L"table"}, L"formatArg"));
187 VERIFY_THROWS(validation::ValidateFormatTypeFromString({L"JSON", L"TABLE", L"csv"}, L"formatArg"), ArgumentException);
188
189 // Verify image pull policy
190 auto pullPolicy = validation::GetPullPolicyFromString(L"always");
191 VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Always);
192 pullPolicy = validation::GetPullPolicyFromString(L"missing");
193 VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Missing);
194 pullPolicy = validation::GetPullPolicyFromString(L"never");
195 VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Never);
196 VERIFY_THROWS(validation::GetPullPolicyFromString(L"invalid"), ArgumentException);
197
198 // Verify build progress mode
199 VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"auto"), ProgressMode::Auto);
200 VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"tty"), ProgressMode::Tty);
201 VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"plain"), ProgressMode::Plain);
202 VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"quiet"), ProgressMode::Quiet);
203 VERIFY_THROWS(validation::GetProgressModeFromString(L"TTY"), ArgumentException); // Case-sensitive: only lowercase accepted
204 VERIFY_THROWS(validation::GetProgressModeFromString(L"fancy"), ArgumentException);
205
206 // Verify Docker-style memory size conversion.
207 VERIFY_ARE_EQUAL(static_cast<int64_t>(1'610'612'736), validation::GetMemorySizeFromString(L"1.5G"));
208 VERIFY_ARE_EQUAL(static_cast<int64_t>(314'572), validation::GetMemorySizeFromString(L"0.3MiB"));
209 VERIFY_ARE_EQUAL(static_cast<int64_t>(32), validation::GetMemorySizeFromString(L"32.3"));
210 VERIFY_ARE_EQUAL(static_cast<int64_t>(9'007'199'254'740'993), validation::GetMemorySizeFromString(L"9007199254740993"));
211 VERIFY_ARE_EQUAL(std::numeric_limits<int64_t>::max(), validation::GetMemorySizeFromString(L"9223372036854775807"));
212 VERIFY_THROWS(validation::GetMemorySizeFromString(L"-1.5G"), ArgumentException);
213 VERIFY_THROWS(validation::GetMemorySizeFromString(L"9223372036854775808"), ArgumentException);
214
215 // Verify GPU device argument
216 VERIFY_NO_THROW(validation::ValidateGpus({L"all"}, L"gpusArg"));
217 VERIFY_THROWS(validation::ValidateGpus({L"none"}, L"gpusArg"), ArgumentException);
218 VERIFY_THROWS(validation::ValidateGpus({L"0"}, L"gpusArg"), ArgumentException);
219 VERIFY_THROWS(validation::ValidateGpus({L"gpu0"}, L"gpusArg"), ArgumentException);
220 VERIFY_THROWS(validation::ValidateGpus({L""}, L"gpusArg"), ArgumentException);
221 }
222
223 // Test: Verify EnumVariantMap behavior with ArgTypes.
224 TEST_METHOD(EnumVariantMap_AllDataTypes)
225 {
226 // ArgMap is an EnumVariantMap
227 ArgMap argsContainer;
228
229 // Verify basic add
230 argsContainer.Add<ArgType::Help>(true);
231 VERIFY_IS_TRUE(argsContainer.Contains(ArgType::Help));
232 argsContainer.Add<ArgType::ContainerId>(std::wstring(L"test"));
233 VERIFY_IS_TRUE(argsContainer.Contains(ArgType::ContainerId));
234 argsContainer.Add<ArgType::ForwardArgs>(std::vector<std::wstring>{L"test1", L"test2"});
235 VERIFY_IS_TRUE(argsContainer.Contains(ArgType::ForwardArgs));
236
237 // Verify basic retrieval
238 auto retrievedBool = argsContainer.GetValue<ArgType::Help>();
239 VERIFY_ARE_EQUAL(retrievedBool, true);
240 auto retrievedString = argsContainer.GetValue<ArgType::ContainerId>();
241 VERIFY_ARE_EQUAL(retrievedString, std::wstring(L"test"));
242 auto retrievedStringSet = argsContainer.GetValue<ArgType::ForwardArgs>();
243 VERIFY_ARE_EQUAL(retrievedStringSet[0], std::wstring(L"test1"));
244 VERIFY_ARE_EQUAL(retrievedStringSet[1], std::wstring(L"test2"));
245
246 // Verify multimap functionality and Runtime Add
247 argsContainer.Add(ArgType::Publish, std::wstring(L"test1"));
248 argsContainer.Add(ArgType::Publish, std::wstring(L"test2"));
249 argsContainer.Add(ArgType::Publish, std::wstring(L"test3"));
250 VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::Publish), 3);
251 auto publishArgs = argsContainer.GetAllValues<ArgType::Publish>();
252 VERIFY_ARE_EQUAL(publishArgs.size(), 3);
253 VERIFY_ARE_EQUAL(publishArgs[0], std::wstring(L"test1"));
254 VERIFY_ARE_EQUAL(publishArgs[1], std::wstring(L"test2"));
255 VERIFY_ARE_EQUAL(publishArgs[2], std::wstring(L"test3"));
256
257 // Verify Remove
258 ArgMap removeArgs;
259 removeArgs.Add<ArgType::Publish>(L"test");
260 removeArgs.Remove(ArgType::Publish);
261 VERIFY_ARE_EQUAL(removeArgs.Count(ArgType::Publish), 0);
262
263 // Verify compile time add works like runtime add for multimap types.
264 ArgMap compileTimeArgs;
265 compileTimeArgs.Add<ArgType::Publish>(L"test1");
266 compileTimeArgs.Add<ArgType::Publish>(L"test2");
267 compileTimeArgs.Add<ArgType::Publish>(L"test3");
268 VERIFY_ARE_EQUAL(compileTimeArgs.Count(ArgType::Publish), 3);
269 publishArgs = compileTimeArgs.GetAllValues<ArgType::Publish>();
270 VERIFY_ARE_EQUAL(publishArgs.size(), 3);
271 VERIFY_ARE_EQUAL(publishArgs[0], std::wstring(L"test1"));
272 VERIFY_ARE_EQUAL(publishArgs[1], std::wstring(L"test2"));
273 VERIFY_ARE_EQUAL(publishArgs[2], std::wstring(L"test3"));
274
275 // Verify Keys
276 auto allArgTypes = argsContainer.GetKeys();
277 VERIFY_ARE_EQUAL(allArgTypes.size(), 4);
278 VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::Help) != allArgTypes.end());
279 VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::ContainerId) != allArgTypes.end());
280 VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::Publish) != allArgTypes.end());
281 VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::ForwardArgs) != allArgTypes.end());
282
283 // Verify count
284 VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::Help), 1);
285 VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::ContainerId), 1);
286 VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::Publish), 3);
287 VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::ForwardArgs), 1);
288 VERIFY_ARE_EQUAL(argsContainer.GetCount(), 6); // 1 Help + 1 ContainerId + 3 Publish + 1 ForwardArgs
289 }
290
291 // Test: Verify the validated-value cache stores and returns converted results so that a
292 // conversion performed during validation is reused during execution. Access is by a compile-time
293 // ArgType, so the value type is fixed by the argument's ConvertedType and cannot be mismatched.
294 TEST_METHOD(ValidatedCache_StoresAndRetrievesConvertedValues)
295 {
296 ArgMap args;
297
298 // Populate raw arguments so the validated-cache invariant (raw count == validated count,
299 // enforced by a debug assert in the cache readers) holds when values are read below.
300 args.Add(ArgType::StopTimeout, std::wstring(L"30"));
301 args.Add(ArgType::Filter, std::wstring(L"status=running"));
302 args.Add(ArgType::Filter, std::wstring(L"label=env=prod"));
303
304 // Nothing cached yet.
305 VERIFY_IS_FALSE(args.ContainsValidated(ArgType::StopTimeout));
306
307 // A conversion that produces a non-string type (string -> int). The value type is fixed by
308 // ArgType::StopTimeout's ConvertedType (int), so no type is supplied by the caller.
309 args.AddValidated<ArgType::StopTimeout>(30);
310 VERIFY_IS_TRUE(args.ContainsValidated(ArgType::StopTimeout));
311 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::StopTimeout), static_cast<size_t>(1));
312 VERIFY_ARE_EQUAL(args.GetValue<ArgType::StopTimeout>(), 30);
313
314 // Multiple cached values for one argument preserve insertion order.
315 args.AddValidated<ArgType::Filter>(std::pair<std::string, std::string>{"status", "running"});
316 args.AddValidated<ArgType::Filter>(std::pair<std::string, std::string>{"label", "env=prod"});
317 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Filter), static_cast<size_t>(2));
318 auto filters = args.GetAllValues<ArgType::Filter>();
319 VERIFY_ARE_EQUAL(filters.size(), static_cast<size_t>(2));
320 VERIFY_ARE_EQUAL(filters[0].first, std::string("status"));
321 VERIFY_ARE_EQUAL(filters[0].second, std::string("running"));
322 VERIFY_ARE_EQUAL(filters[1].first, std::string("label"));
323 VERIFY_ARE_EQUAL(filters[1].second, std::string("env=prod"));
324
325 // GetAllValidated returns empty when nothing is cached for the argument.
326 auto empty = args.GetAllValues<ArgType::Signal>();
327 VERIFY_IS_TRUE(empty.empty());
328
329 // An absent argument resolves to its value type's default-constructed value.
330 VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Memory));
331 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Memory), static_cast<size_t>(0));
332 VERIFY_ARE_EQUAL(args.GetValue<ArgType::Memory>(), int64_t{});
333 VERIFY_IS_FALSE(args.Contains(ArgType::Memory));
334 }
335
336 // Helper: run validation for a single-value argument and return the converted result (type fixed
337 // by the argument's ConvertedType). Drives both paths for every converted ArgType its callers
338 // exercise: the eager path (an explicit validation pass) and the on-demand path (a converted read
339 // with no prior validation pass, which must self-validate). The returned value is the on-demand
340 // result, so callers' expected-value assertions verify the on-demand output equals what the
341 // validation pass produces. Both paths run the same Argument::Validate, so their results match by
342 // construction; this asserts the on-demand trigger fires and caches an equal number of values.
343 template <ArgType E>
344 static auto ValidateAndGetCached(const std::wstring& raw)
345 {
346 ArgMap eager;
347 eager.Add(E, std::wstring(raw));
348 Argument::Create(E).Validate(eager);
349 VERIFY_IS_TRUE(eager.ContainsValidated(E));
350
351 ArgMap onDemand;
352 onDemand.Add(E, std::wstring(raw));
353 VERIFY_IS_FALSE(onDemand.ContainsValidated(E)); // no validation pass ran
354 auto value = onDemand.GetValue<E>(); // triggers on-demand validation
355 VERIFY_IS_TRUE(onDemand.ContainsValidated(E));
356 VERIFY_ARE_EQUAL(onDemand.CountValidated(E), eager.CountValidated(E));
357 return value;
358 }
359
360 // Helper: as ValidateAndGetCached, for an argument that appears multiple times (ArgMap is a
361 // multimap). Runs the eager and on-demand paths and returns every on-demand converted value in
362 // insertion order.
363 template <ArgType E>
364 static auto ValidateAndGetAllCached(const std::vector<std::wstring>& raws)
365 {
366 ArgMap eager;
367 for (const auto& raw : raws)
368 {
369 eager.Add(E, std::wstring(raw));
370 }
371
372 Argument::Create(E).Validate(eager);
373
374 // The cache must hold exactly one converted value per raw value in the map.
375 VERIFY_ARE_EQUAL(eager.CountValidated(E), eager.Count(E));
376 VERIFY_ARE_EQUAL(eager.CountValidated(E), raws.size());
377
378 ArgMap onDemand;
379 for (const auto& raw : raws)
380 {
381 onDemand.Add(E, std::wstring(raw));
382 }
383
384 VERIFY_IS_FALSE(onDemand.ContainsValidated(E)); // no validation pass ran
385 auto values = onDemand.GetAllValues<E>(); // triggers on-demand validation
386 VERIFY_ARE_EQUAL(onDemand.CountValidated(E), eager.CountValidated(E));
387 return values;
388 }
389
390 // Test: Every ArgType whose validation converts its raw string into a typed value must cache
391 // that value on the ArgMap during Argument::Validate, so execution reads it back without
392 // re-converting. This drives the real validation + caching path for each converted ArgType.
393 TEST_METHOD(ArgumentValidate_ConvertsAndCachesEveryConvertedArgType)
394 {
395 // string -> FormatType
396 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Format>(L"json"), FormatType::Json);
397
398 // string -> json::dump() indentation
399 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::InspectFormat>(L"json"), wsl::shared::c_jsonCompactIndent);
400
401 // string -> PullPolicy
402 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Pull>(L"missing"), PullPolicy::Missing);
403 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Pull>(L"always"), PullPolicy::Always);
404 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Pull>(L"never"), PullPolicy::Never);
405
406 // string -> WSLCSignal (Signal and StopSignal share the converter)
407 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Signal>(L"SIGTERM"), WSLCSignalSIGTERM);
408 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::StopSignal>(L"SIGKILL"), WSLCSignalSIGKILL);
409
410 // string -> int
411 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::StopTimeout>(L"30"), 30);
412 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthRetries>(L"3"), 3);
413 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Last>(L"5"), 5);
414
415 // string -> LONG (Time and Timeout share the converter)
416 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Time>(L"5"), 5L);
417 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Timeout>(L"5"), 5L);
418
419 // string -> ULONGLONG (Tail is a raw integer; Since/Until go through the timestamp parser)
420 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Tail>(L"10"), 10ULL);
421 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Since>(L"100"), validation::GetTimestampFromString(L"100"));
422 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Until>(L"200"), validation::GetTimestampFromString(L"200"));
423
424 // string -> int64_t (memory sizes). The cached value matches the converter's result.
425 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Memory>(L"512M"), validation::GetMemorySizeFromString(L"512M"));
426 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::ShmSize>(L"1.5G"), static_cast<int64_t>(1'610'612'736));
427
428 // string -> int64_t (durations, in nanoseconds)
429 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthInterval>(L"30s"), validation::GetDurationNanosFromString(L"30s"));
430 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthTimeout>(L"30s"), validation::GetDurationNanosFromString(L"30s"));
431 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthStartPeriod>(L"30s"), validation::GetDurationNanosFromString(L"30s"));
432
433 // string -> int64_t (nano CPUs)
434 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Cpus>(L"1.5"), validation::GetNanoCpusFromString(L"1.5"));
435
436 // mount strings -> mount::Spec
437 {
438 const auto volume = ValidateAndGetCached<ArgType::Volume>(LR"(C:\hostPath:/containerPath)");
439 VERIFY_ARE_EQUAL(static_cast<int>(WSLCMountTypeBind), static_cast<int>(volume.MountType));
440 VERIFY_ARE_EQUAL(static_cast<int>(mount::BindSourcePolicy::CreateIfMissing), static_cast<int>(volume.BindSource));
441
442 const auto tmpfs = ValidateAndGetCached<ArgType::TMPFS>(L"/tmp:size=64k");
443 VERIFY_ARE_EQUAL(static_cast<int>(WSLCMountTypeTmpfs), static_cast<int>(tmpfs.MountType));
444 VERIFY_IS_TRUE(tmpfs.TmpfsOptions.has_value());
445 VERIFY_ARE_EQUAL(std::string("size=64k"), tmpfs.TmpfsOptions.value());
446
447 const auto structured = ValidateAndGetCached<ArgType::Mount>(L"type=volume,source=data-volume,target=/data");
448 VERIFY_ARE_EQUAL(static_cast<int>(WSLCMountTypeVolume), static_cast<int>(structured.MountType));
449 VERIFY_ARE_EQUAL(std::wstring(L"data-volume"), structured.Source);
450 }
451
452 // string -> tuple<name, soft, hard> (ulimit)
453 auto ulimit = ValidateAndGetCached<ArgType::Ulimit>(L"nofile=1024:2048");
454 VERIFY_ARE_EQUAL(std::get<0>(ulimit), std::string("nofile"));
455 VERIFY_ARE_EQUAL(std::get<1>(ulimit), 1024LL);
456 VERIFY_ARE_EQUAL(std::get<2>(ulimit), 2048LL);
457
458 // string -> pair<key, value> (filter). A single Validate call caches every raw value in order.
459 {
460 ArgMap args;
461 args.Add(ArgType::Filter, std::wstring(L"status=running"));
462 args.Add(ArgType::Filter, std::wstring(L"label=env=prod")); // split on first '='
463 Argument::Create(ArgType::Filter).Validate(args);
464 auto filters = args.GetAllValues<ArgType::Filter>();
465 VERIFY_ARE_EQUAL(filters.size(), static_cast<size_t>(2));
466 VERIFY_ARE_EQUAL(filters[0].first, std::string("status"));
467 VERIFY_ARE_EQUAL(filters[0].second, std::string("running"));
468 VERIFY_ARE_EQUAL(filters[1].first, std::string("label"));
469 VERIFY_ARE_EQUAL(filters[1].second, std::string("env=prod"));
470 }
471
472 // string -> InspectType (inspect object type)
473 VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Type>(L"container"), InspectType::Container);
474
475 // string -> BuildOutput (docker-style build exporter spec)
476 {
477 auto output = ValidateAndGetCached<ArgType::BuildOutput>(L"type=tar,dest=-");
478 VERIFY_ARE_EQUAL(output.Type, std::wstring(L"tar"));
479 VERIFY_ARE_EQUAL(output.Dest, std::wstring(L"-"));
480 }
481
482 // string -> pair<key, value> (label and driver option share the key=value shape)
483 {
484 auto label = ValidateAndGetCached<ArgType::Label>(L"env=prod");
485 VERIFY_ARE_EQUAL(label.first, std::string("env"));
486 VERIFY_ARE_EQUAL(label.second, std::string("prod"));
487
488 auto option = ValidateAndGetCached<ArgType::Options>(L"com.docker.network.bridge.name=br0");
489 VERIFY_ARE_EQUAL(option.first, std::string("com.docker.network.bridge.name"));
490 VERIFY_ARE_EQUAL(option.second, std::string("br0"));
491 }
492
493 // string -> BuildSecret (docker-style --secret spec resolved to an id and value bytes)
494 {
495 ScopedEnvVariable env(L"WSLC_UT_CONV_SECRET", L"conv-value");
496 auto secret = ValidateAndGetCached<ArgType::Secret>(L"id=convtest,env=WSLC_UT_CONV_SECRET");
497 VERIFY_ARE_EQUAL(secret.Id, std::wstring(L"convtest"));
498 const std::string expected = "conv-value";
499 VERIFY_IS_TRUE(std::vector<BYTE>(expected.begin(), expected.end()) == secret.Value);
500 }
501
502 // string -> ParsedNetworkArgument (docker-style network name and aliases)
503 {
504 auto network = ValidateAndGetCached<ArgType::Network>(L"name=custom,alias=web");
505 VERIFY_ARE_EQUAL(network.Name, std::string("custom"));
506 VERIFY_ARE_EQUAL(network.Aliases.size(), static_cast<size_t>(1));
507 VERIFY_ARE_EQUAL(network.Aliases[0], std::string("web"));
508 }
509 }
510
511 // Test: Because ArgMap is a multimap and any command may allow an argument to repeat, a single
512 // Argument::Validate call must convert and cache every occurrence, in order. Covers the
513 // different converter result shapes (integer, enum, tuple, pair). The helper also asserts the
514 // cached count matches the number of raw values in the map.
515 TEST_METHOD(ArgumentValidate_CachesEveryValueForRepeatedArg)
516 {
517 // Integer converter, multiple values -> all cached in order.
518 auto retries = ValidateAndGetAllCached<ArgType::HealthRetries>({L"1", L"2", L"3"});
519 VERIFY_ARE_EQUAL(retries.size(), static_cast<size_t>(3));
520 VERIFY_ARE_EQUAL(retries[0], 1);
521 VERIFY_ARE_EQUAL(retries[1], 2);
522 VERIFY_ARE_EQUAL(retries[2], 3);
523
524 // int64_t converter (memory sizes), multiple values -> all cached in order.
525 auto memories = ValidateAndGetAllCached<ArgType::Memory>({L"128M", L"256M"});
526 VERIFY_ARE_EQUAL(memories.size(), static_cast<size_t>(2));
527 VERIFY_ARE_EQUAL(memories[0], validation::GetMemorySizeFromString(L"128M"));
528 VERIFY_ARE_EQUAL(memories[1], validation::GetMemorySizeFromString(L"256M"));
529
530 // Enum converter, multiple values -> all cached in order.
531 auto signals = ValidateAndGetAllCached<ArgType::Signal>({L"SIGTERM", L"SIGKILL", L"SIGHUP"});
532 VERIFY_ARE_EQUAL(signals.size(), static_cast<size_t>(3));
533 VERIFY_ARE_EQUAL(signals[0], WSLCSignalSIGTERM);
534 VERIFY_ARE_EQUAL(signals[1], WSLCSignalSIGKILL);
535 VERIFY_ARE_EQUAL(signals[2], WSLCSignalSIGHUP);
536
537 // Tuple converter (ulimit), multiple values -> all cached in order.
538 auto ulimits = ValidateAndGetAllCached<ArgType::Ulimit>({L"nofile=1024:2048", L"nproc=512:1024"});
539 VERIFY_ARE_EQUAL(ulimits.size(), static_cast<size_t>(2));
540 VERIFY_ARE_EQUAL(std::get<0>(ulimits[0]), std::string("nofile"));
541 VERIFY_ARE_EQUAL(std::get<1>(ulimits[0]), 1024LL);
542 VERIFY_ARE_EQUAL(std::get<2>(ulimits[0]), 2048LL);
543 VERIFY_ARE_EQUAL(std::get<0>(ulimits[1]), std::string("nproc"));
544 VERIFY_ARE_EQUAL(std::get<1>(ulimits[1]), 512LL);
545 VERIFY_ARE_EQUAL(std::get<2>(ulimits[1]), 1024LL);
546
547 // Pair converter (filter), multiple values -> all cached in order.
548 auto filters = ValidateAndGetAllCached<ArgType::Filter>({L"status=running", L"name=web", L"label=env=prod"});
549 VERIFY_ARE_EQUAL(filters.size(), static_cast<size_t>(3));
550 VERIFY_ARE_EQUAL(filters[0].first, std::string("status"));
551 VERIFY_ARE_EQUAL(filters[0].second, std::string("running"));
552 VERIFY_ARE_EQUAL(filters[1].first, std::string("name"));
553 VERIFY_ARE_EQUAL(filters[1].second, std::string("web"));
554 VERIFY_ARE_EQUAL(filters[2].first, std::string("label"));
555 VERIFY_ARE_EQUAL(filters[2].second, std::string("env=prod"));
556
557 // BuildSecret converter (secret specs), multiple values -> all cached in order.
558 {
559 ScopedEnvVariable envA(L"WSLC_UT_CONV_SECRET_A", L"value-a");
560 ScopedEnvVariable envB(L"WSLC_UT_CONV_SECRET_B", L"value-b");
561 auto secrets = ValidateAndGetAllCached<ArgType::Secret>(
562 {L"id=seca,env=WSLC_UT_CONV_SECRET_A", L"id=secb,env=WSLC_UT_CONV_SECRET_B"});
563 VERIFY_ARE_EQUAL(secrets.size(), static_cast<size_t>(2));
564 VERIFY_ARE_EQUAL(secrets[0].Id, std::wstring(L"seca"));
565 VERIFY_ARE_EQUAL(secrets[1].Id, std::wstring(L"secb"));
566 const std::string expectedA = "value-a";
567 const std::string expectedB = "value-b";
568 VERIFY_IS_TRUE(std::vector<BYTE>(expectedA.begin(), expectedA.end()) == secrets[0].Value);
569 VERIFY_IS_TRUE(std::vector<BYTE>(expectedB.begin(), expectedB.end()) == secrets[1].Value);
570 }
571 }
572
573 // Test: Every validate-only ArgType (checked during validation but not converted into a
574 // distinct typed value that execution consumes) must NOT populate the cache. Execution reads
575 // the raw value for these instead.
576 TEST_METHOD(ArgumentValidate_ValidateOnlyArgsAreNotCached)
577 {
578 struct Case
579 {
580 ArgType Type;
581 std::wstring Value;
582 };
583
584 const std::vector<Case> cases = {
585 {ArgType::Gpus, L"all"},
586 {ArgType::WorkDir, L"/app"},
587 {ArgType::NetworkAlias, L"myalias"},
588 };
589
590 for (const auto& c : cases)
591 {
592 ArgMap args;
593 args.Add(c.Type, std::wstring(c.Value));
594 Argument::Create(c.Type).Validate(args);
595 VERIFY_IS_FALSE(args.ContainsValidated(c.Type));
596 }
597
598 // NoHealthcheck is a flag whose validation only rejects conflicting health options. With
599 // no conflicts present it passes and caches nothing.
600 ArgMap noHealthcheck;
601 noHealthcheck.Add(ArgType::NoHealthcheck, true);
602 Argument::Create(ArgType::NoHealthcheck).Validate(noHealthcheck);
603 VERIFY_IS_FALSE(noHealthcheck.ContainsValidated(ArgType::NoHealthcheck));
604 }
605
606 // Test: When conversion fails during validation, Validate throws and nothing is cached.
607 TEST_METHOD(ArgumentValidate_InvalidValueThrowsAndCachesNothing)
608 {
609 ArgMap args;
610 args.Add(ArgType::Format, std::wstring(L"xml"));
611 VERIFY_THROWS(Argument::Create(ArgType::Format).Validate(args), ArgumentException);
612 VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Format));
613 }
614
615 // Note: on-demand validation for every converted ArgType (reading with no prior validation pass
616 // and getting the same result the pass produces) is covered by the tests above:
617 // ValidateAndGetCached / ValidateAndGetAllCached drive both the eager and on-demand paths and
618 // return the on-demand value, so those tests' expected-value assertions verify on-demand output
619 // for all converted shapes. The tests below cover the behaviors unique to the on-demand trigger:
620 // a bad value fails the same way as on the command line, a value added after the validation pass
621 // is re-validated, and validate-only arguments (no converted value) are checked on demand too.
622
623 // Test: An invalid value read on demand (no prior validation pass) throws ArgumentException --
624 // the same failure the up-front validation pass raises for that value. This proves an argument
625 // populated during execution routes to the same user error path as a bad command-line value,
626 // and that a failed on-demand validation leaves nothing cached.
627 TEST_METHOD(ArgumentValidate_OnDemandInvalidValueThrows)
628 {
629 ArgMap args;
630 args.Add(ArgType::Format, std::wstring(L"xml")); // not a valid FormatType
631 VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Format));
632 VERIFY_THROWS(args.GetValue<ArgType::Format>(), ArgumentException);
633 VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Format));
634 }
635
636 // Test: Raw values can change after the up-front validation pass until the argument is read.
637 // The mutation invalidates the cache, and the first read validates the final values.
638 TEST_METHOD(ArgumentValidate_PostValidationAddBeforeReadRevalidates)
639 {
640 ArgMap args;
641 args.Add(ArgType::Signal, std::wstring(L"SIGTERM"));
642 Argument::Create(ArgType::Signal).Validate(args);
643 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(1));
644
645 // Add a second raw value before the first read. The map-action callback drops the cache.
646 args.Add(ArgType::Signal, std::wstring(L"SIGKILL"));
647 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(0));
648
649 // The first read re-validates both raw values on demand, in insertion order.
650 auto signals = args.GetAllValues<ArgType::Signal>();
651 VERIFY_ARE_EQUAL(signals.size(), static_cast<size_t>(2));
652 VERIFY_ARE_EQUAL(signals[0], WSLCSignalSIGTERM);
653 VERIFY_ARE_EQUAL(signals[1], WSLCSignalSIGKILL);
654 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(2));
655 }
656
657 // Test: Arguments are validated on demand when read, so a value added after the up-front pass is
658 // checked exactly as a command-line value.
659 TEST_METHOD(ArgumentValidate_OnDemandArgIsChecked)
660 {
661 ArgMap labels;
662 labels.Add(ArgType::BuildLabel, std::wstring(L"foo"));
663 labels.Add(ArgType::BuildLabel, std::wstring(L"foo="));
664 auto labelValues = labels.GetAllValues<ArgType::BuildLabel>();
665 VERIFY_ARE_EQUAL(labelValues.size(), static_cast<size_t>(2));
666 VERIFY_ARE_EQUAL(labelValues[0], std::wstring(L"foo"));
667 VERIFY_ARE_EQUAL(labelValues[1], std::wstring(L"foo="));
668
669 ArgMap invalidLabel;
670 invalidLabel.Add(ArgType::BuildLabel, std::wstring(L"=value"));
671 VERIFY_THROWS(invalidLabel.GetAllValues<ArgType::BuildLabel>(), wil::ResultException);
672
673 // Valid value, no prior validation pass: the read validates and converts on demand.
674 ArgMap valid;
675 valid.Add(ArgType::Network, std::wstring(L"name=custom,alias=web"));
676 auto networks = valid.GetAllValues<ArgType::Network>();
677 VERIFY_ARE_EQUAL(networks.size(), static_cast<size_t>(1));
678 VERIFY_ARE_EQUAL(networks[0].Name, std::string("custom"));
679 VERIFY_ARE_EQUAL(networks[0].Aliases.size(), static_cast<size_t>(1));
680 VERIFY_ARE_EQUAL(networks[0].Aliases[0], std::string("web"));
681
682 // Invalid value, no prior validation pass: the read validates on demand and throws, matching
683 // the failure the up-front pass raises for the same value.
684 ArgMap invalid;
685 invalid.Add(ArgType::Network, std::wstring(L"host"));
686 VERIFY_THROWS(invalid.GetAllValues<ArgType::Network>(), ExecutionException);
687
688 // Valid up-front, then an unsupported value added before the first read: the map-action
689 // callback clears the validated record, so the read re-validates on demand and throws.
690 ArgMap added;
691 added.Add(ArgType::Network, std::wstring(L"bridge"));
692 Argument::Create(ArgType::Network).Validate(added);
693 added.Add(ArgType::Network, std::wstring(L"host"));
694 VERIFY_THROWS(added.GetAllValues<ArgType::Network>(), ExecutionException);
695 }
696
697 TEST_METHOD(ArgumentValidate_ReadMakesArgumentImmutable)
698 {
699 ArgMap args;
700 args.Add(ArgType::Signal, std::wstring(L"SIGTERM"));
701 VERIFY_ARE_EQUAL(args.GetValue<ArgType::Signal>(), WSLCSignalSIGTERM);
702 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(1));
703
704 Argument::Create(ArgType::Signal).Validate(args);
705 args.MarkValidated(ArgType::Signal);
706 VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(1));
707
708 const auto verifyImmutableFailure = [](const auto& operation) {
709 VERIFY_THROWS_SPECIFIC(operation(), wil::ResultException, [](const wil::ResultException& e) {
710 return e.GetErrorCode() == E_ILLEGAL_METHOD_CALL;
711 });
712 };
713
714 verifyImmutableFailure([&] { args.Add(ArgType::Signal, std::wstring(L"SIGKILL")); });
715 verifyImmutableFailure([&] { args.Remove(ArgType::Signal); });
716 verifyImmutableFailure([&] { args.InvalidateValidated(ArgType::Signal); });
717 verifyImmutableFailure([&] { args.AddValidated<ArgType::Signal>(WSLCSignalSIGKILL); });
718
719 // Immutability is per argument; other arguments remain writable until they are read.
720 args.Add(ArgType::StopTimeout, std::wstring(L"30"));
721 VERIFY_ARE_EQUAL(args.GetValue<ArgType::StopTimeout>(), 30);
722 }
723
724 TEST_METHOD(ArgumentValidate_FlagReadValidatesAndMakesArgumentImmutable)
725 {
726 const auto verifyImmutableFailure = [](const auto& operation) {
727 VERIFY_THROWS_SPECIFIC(operation(), wil::ResultException, [](const wil::ResultException& e) {
728 return e.GetErrorCode() == E_ILLEGAL_METHOD_CALL;
729 });
730 };
731
732 ArgMap absent;
733 VERIFY_IS_FALSE(absent.GetValue<ArgType::Quiet>());
734 VERIFY_IS_FALSE(absent.GetValue<ArgType::Quiet>(true));
735 VERIFY_IS_FALSE(absent.GetValue<ArgType::Quiet>());
736 VERIFY_IS_FALSE(absent.Contains(ArgType::Quiet));
737 verifyImmutableFailure([&] { absent.Add(ArgType::Quiet, true); });
738
739 ArgMap present;
740 present.Add(ArgType::NoHealthcheck, true);
741 present.Add(ArgType::HealthCmd, std::wstring(L"CMD echo healthy"));
742 VERIFY_THROWS(present.GetValue<ArgType::NoHealthcheck>(), ArgumentException);
743
744 // A failed read does not freeze the argument, so correcting the conflicting input permits
745 // a subsequent successful read.
746 present.Remove(ArgType::HealthCmd);
747 VERIFY_IS_TRUE(present.GetValue<ArgType::NoHealthcheck>());
748 verifyImmutableFailure([&] { present.Remove(ArgType::NoHealthcheck); });
749 }
750
751 // Timestamp parsing unit tests (exercises TryParseRfc3339 and integer path via GetTimestampFromString)
752
753 TEST_METHOD(ValidateTimestamp_ValidUnixEpochSeconds)
754 {
755 // Integer timestamps should parse directly
756 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"0"), 0LL);
757 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1700000000"), 1700000000LL);
758 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1"), 1LL);
759 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"9999999999"), 9999999999LL);
760 }
761
762 TEST_METHOD(ValidateTimestamp_ValidRfc3339_UTC)
763 {
764 // Basic UTC timestamps with Z suffix
765 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00Z"), 1705314600LL);
766 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1970-01-01T00:00:00Z"), 0LL);
767 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00z"), 1705314600LL); // lowercase z
768 }
769
770 TEST_METHOD(ValidateTimestamp_ValidRfc3339_WithOffset)
771 {
772 // Timestamps with timezone offsets (+HH:MM / -HH:MM)
773 // 2024-01-15T10:30:00+05:30 = 2024-01-15T05:00:00Z = 1705294800
774 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00+05:30"), 1705294800LL);
775 // 2024-01-15T10:30:00-05:00 = 2024-01-15T15:30:00Z = 1705332600
776 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00-05:00"), 1705332600LL);
777 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00+00:00"), 1705314600LL);
778 }
779
780 TEST_METHOD(ValidateTimestamp_ValidRfc3339_FractionalSeconds)
781 {
782 // Fractional seconds should be consumed (truncated to seconds)
783 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.123Z"), 1705314600LL);
784 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.123456789Z"), 1705314600LL);
785 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.1+05:30"), 1705294800LL);
786 }
787
788 TEST_METHOD(ValidateTimestamp_ValidPreEpoch)
789 {
790 // No lower bound is applied, so pre-1970 values convert to a negative epoch.
791 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1960-01-15T10:30:00Z"), -314371800LL);
792 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"0001-01-01T00:00:00Z"), -62135596800LL);
793 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"-314371800"), -314371800LL);
794 }
795
796 TEST_METHOD(ValidateTimestamp_OutsideNanosecondRange)
797 {
798 // Values beyond the range of a nanosecond representation still convert exactly.
799 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1600-01-01T00:00:00Z"), -11676096000LL);
800 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2300-01-01T00:00:00Z"), 10413792000LL);
801 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"9999-12-31T23:59:59Z"), 253402300799LL);
802 }
803
804 TEST_METHOD(ValidateTimestamp_ValidZoneLessLocalTime)
805 {
806 // A value with no zone designator is resolved against the local UTC offset.
807 const auto offset = std::chrono::duration_cast<std::chrono::seconds>(
808 std::chrono::current_zone()->get_info(std::chrono::system_clock::now()).offset)
809 .count();
810 const auto expected = 1705314600LL - offset;
811
812 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00"), expected);
813 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.123"), expected);
814 }
815
816 TEST_METHOD(ValidateTimestamp_ValidPartialAndDateOnly)
817 {
818 // Hour-only, minute-only and date-only values are padded out to a full time.
819 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15"), validation::GetTimestampFromString(L"2024-01-15T00:00:00"));
820 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10"), validation::GetTimestampFromString(L"2024-01-15T10:00:00"));
821 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30"), validation::GetTimestampFromString(L"2024-01-15T10:30:00"));
822
823 // The same padding applies when an explicit zone is present.
824 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10Z"), 1705312800LL);
825 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30Z"), 1705314600LL);
826 VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15Z"), 1705276800LL);
827 }
828
829 TEST_METHOD(ValidateTimestamp_ValidGoDuration)
830 {
831 const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
832
833 // Durations are measured back from the current time, so allow a small window for the clock
834 // to advance while the test runs.
835 auto verifyDuration = [&](LPCWSTR value, LONGLONG expectedOffset) {
836 const auto parsed = validation::GetTimestampFromString(value);
837 VERIFY_IS_GREATER_THAN_OR_EQUAL(parsed, now - expectedOffset);
838 VERIFY_IS_LESS_THAN_OR_EQUAL(parsed, now - expectedOffset + 30);
839 };
840
841 verifyDuration(L"10m", 600LL);
842 verifyDuration(L"1h30m", 5400LL);
843 verifyDuration(L"90s", 90LL);
844 verifyDuration(L"1.5h", 5400LL);
845 verifyDuration(L"2h45m30s", 9930LL);
846
847 // A negative duration selects a time in the future.
848 verifyDuration(L"-1h", -3600LL);
849 }
850
851 TEST_METHOD(ValidateTimestamp_SubSecondGoDuration)
852 {
853 // A sub-second duration is applied before the value is truncated, so a negative one still
854 // resolves at or after the current second rather than being rounded away.
855 const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
856
857 const auto future = validation::GetTimestampFromString(L"-500ms");
858 VERIFY_IS_GREATER_THAN_OR_EQUAL(future, now);
859 VERIFY_IS_LESS_THAN_OR_EQUAL(future, now + 30);
860
861 const auto past = validation::GetTimestampFromString(L"500ms");
862 VERIFY_IS_GREATER_THAN_OR_EQUAL(past, now - 1);
863 VERIFY_IS_LESS_THAN_OR_EQUAL(past, now + 30);
864 }
865
866 TEST_METHOD(ValidateTimestamp_InvalidRfc3339_Rejected)
867 {
868 // Invalid month
869 VERIFY_THROWS(validation::GetTimestampFromString(L"2024-13-15T10:30:00Z"), ArgumentException);
870 // Invalid hour
871 VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T25:30:00Z"), ArgumentException);
872 // Invalid day (Feb 31)
873 VERIFY_THROWS(validation::GetTimestampFromString(L"2024-02-31T10:30:00Z"), ArgumentException);
874 // Trailing characters
875 VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T10:30:00Zextra"), ArgumentException);
876 // +HHMM without colon (not supported by %Ez)
877 VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T10:30:00+0530"), ArgumentException);
878 // Dot with no fractional digits
879 VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T10:30:00.Z"), ArgumentException);
880 // Random text
881 VERIFY_THROWS(validation::GetTimestampFromString(L"abc"), ArgumentException);
882 VERIFY_THROWS(validation::GetTimestampFromString(L"not-a-timestamp"), ArgumentException);
883 // Duration with no unit
884 VERIFY_THROWS(validation::GetTimestampFromString(L"10x"), ArgumentException);
885 VERIFY_THROWS(validation::GetTimestampFromString(L"1h30"), ArgumentException);
886 }
887 };
888 } // namespace WSLCCLIArgumentUnitTests