mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-18 03:53:56 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a2793c32 | ||
|
|
0fc3d3d1f4 | ||
|
|
60b705478b | ||
|
|
fa9e3146c1 | ||
|
|
3dfe8f870c | ||
|
|
7afa446a73 | ||
|
|
d1bed25023 | ||
|
|
b39c3c9b52 | ||
|
|
8554e56e4b | ||
|
|
3fdcc5c33c | ||
|
|
182ddf0268 |
@@ -35,3 +35,5 @@ brews:
|
|||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS VMs on Apple Silicon
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
|
custom_block: |
|
||||||
|
depends_on :macos => :monterey
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
* Use Tart Packer Plugin to automate VM creation.
|
* Use Tart Packer Plugin to automate VM creation.
|
||||||
* Built-in CI integration.
|
* Built-in CI integration.
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
@@ -20,9 +20,13 @@ tart run monterey-base
|
|||||||
|
|
||||||
## CI Integration
|
## CI Integration
|
||||||
|
|
||||||
[Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is an open-sourced CI-agnostic tool that can run workloads
|
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||||
inside containers via Docker or Podman and now inside macOS VMs via Tart. Put the following `.cirrus.yml` file
|
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
||||||
in the root of your repository:
|
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||||
|
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
||||||
|
|
||||||
|
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
|
||||||
|
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
task:
|
task:
|
||||||
@@ -37,7 +41,7 @@ task:
|
|||||||
- sleep 15
|
- sleep 15
|
||||||
```
|
```
|
||||||
|
|
||||||
Run it locally or in CI with the following command:
|
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
brew install cirruslabs/cli/cirrus
|
brew install cirruslabs/cli/cirrus
|
||||||
@@ -78,7 +82,7 @@ Please refer to `tart set --help` for additional details.
|
|||||||
|
|
||||||
### Building with Packer
|
### Building with Packer
|
||||||
|
|
||||||
Please refer to [Tart Packer Plugin reposiotry](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct Create: AsyncParsableCommand {
|
|||||||
var fromIPSW: String?
|
var fromIPSW: String?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||||
var diskSize: UInt8 = 32
|
var diskSize: UInt8 = 50
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if fromIPSW == nil {
|
if fromIPSW == nil {
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ struct Pull: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
|
// Be more liberal when accepting local image as argument,
|
||||||
|
// see https://github.com/cirruslabs/tart/issues/36
|
||||||
|
if VMStorageLocal().exists(remoteName) {
|
||||||
|
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||||
|
|
||||||
|
Foundation.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
let remoteName = try RemoteName(remoteName)
|
let remoteName = try RemoteName(remoteName)
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||||
|
|
||||||
|
|||||||
@@ -18,51 +18,56 @@ struct Run: AsyncParsableCommand {
|
|||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
vm = try VM(vmDir: vmDir)
|
vm = try VM(vmDir: vmDir)
|
||||||
|
|
||||||
Task {
|
await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
do {
|
group.addTask {
|
||||||
try await vm!.run()
|
do {
|
||||||
|
try await vm!.run()
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
print(error)
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if noGraphics {
|
|
||||||
dispatchMain()
|
|
||||||
} else {
|
|
||||||
// UI mumbo-jumbo
|
|
||||||
let nsApp = NSApplication.shared
|
|
||||||
nsApp.setActivationPolicy(.regular)
|
|
||||||
nsApp.activate(ignoringOtherApps: true)
|
|
||||||
|
|
||||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
|
||||||
|
|
||||||
struct MainApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup(vm!.name) {
|
|
||||||
Group {
|
|
||||||
VMView(vm: vm!).onAppear {
|
|
||||||
NSWindow.allowsAutomaticWindowTabbing = false
|
|
||||||
}
|
|
||||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
|
||||||
}.commands {
|
|
||||||
// Remove some standard menu options
|
|
||||||
CommandGroup(replacing: .help, addition: {})
|
|
||||||
CommandGroup(replacing: .newItem, addition: {})
|
|
||||||
CommandGroup(replacing: .pasteboard, addition: {})
|
|
||||||
CommandGroup(replacing: .textEditing, addition: {})
|
|
||||||
CommandGroup(replacing: .undoRedo, addition: {})
|
|
||||||
CommandGroup(replacing: .windowSize, addition: {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MainApp.main()
|
if noGraphics {
|
||||||
|
dispatchMain()
|
||||||
|
} else {
|
||||||
|
runUI()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func runUI() {
|
||||||
|
let nsApp = NSApplication.shared
|
||||||
|
nsApp.setActivationPolicy(.regular)
|
||||||
|
nsApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||||
|
|
||||||
|
struct MainApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup(vm!.name) {
|
||||||
|
Group {
|
||||||
|
VMView(vm: vm!).onAppear {
|
||||||
|
NSWindow.allowsAutomaticWindowTabbing = false
|
||||||
|
}
|
||||||
|
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||||
|
}.commands {
|
||||||
|
// Remove some standard menu options
|
||||||
|
CommandGroup(replacing: .help, addition: {})
|
||||||
|
CommandGroup(replacing: .newItem, addition: {})
|
||||||
|
CommandGroup(replacing: .pasteboard, addition: {})
|
||||||
|
CommandGroup(replacing: .textEditing, addition: {})
|
||||||
|
CommandGroup(replacing: .undoRedo, addition: {})
|
||||||
|
CommandGroup(replacing: .windowSize, addition: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MainApp.main()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VMView: NSViewRepresentable {
|
struct VMView: NSViewRepresentable {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum CredentialsError: Error {
|
||||||
|
case CredentialRequired(which: String)
|
||||||
|
case CredentialTooLong(message: String)
|
||||||
|
}
|
||||||
|
|
||||||
class Credentials {
|
class Credentials {
|
||||||
static func retrieveKeychain(host: String) throws -> (String, String)? {
|
static func retrieveKeychain(host: String) throws -> (String, String)? {
|
||||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
@@ -34,13 +39,26 @@ class Credentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func retrieveStdin() throws -> (String, String) {
|
static func retrieveStdin() throws -> (String, String) {
|
||||||
print("User: ", terminator: "")
|
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
|
||||||
let user = readLine() ?? ""
|
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
|
||||||
|
|
||||||
let rawPass = getpass("Password: ")
|
return (user, password)
|
||||||
let pass = String(cString: rawPass!, encoding: .utf8)!
|
}
|
||||||
|
|
||||||
return (user, pass)
|
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
||||||
|
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||||
|
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||||
|
throw CredentialsError.CredentialRequired(which: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
|
||||||
|
|
||||||
|
if credential.count > maxCharacters {
|
||||||
|
throw CredentialsError.CredentialTooLong(
|
||||||
|
message: "\(name) should contain no more than \(maxCharacters) characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
return credential
|
||||||
}
|
}
|
||||||
|
|
||||||
static func store(host: String, user: String, password: String) throws {
|
static func store(host: String, user: String, password: String) throws {
|
||||||
|
|||||||
@@ -8,11 +8,31 @@ enum RegistryError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TokenResponse: Decodable {
|
struct TokenResponse: Decodable {
|
||||||
let creationTime = Date()
|
let defaultIssuedAt = Date()
|
||||||
|
let defaultExpiresIn = 60
|
||||||
|
|
||||||
var token: String
|
var token: String
|
||||||
var expires_in: Int?
|
var expiresIn: Int?
|
||||||
|
var issuedAt: Date?
|
||||||
|
|
||||||
|
static func parse(fromData: Data) throws -> Self {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
|
||||||
|
// RFC3339 date formatter from Apple's documentation[1]
|
||||||
|
//
|
||||||
|
// [1]: https://developer.apple.com/documentation/foundation/dateformatter
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
||||||
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
|
decoder.dateDecodingStrategy = .formatted(dateFormatter)
|
||||||
|
|
||||||
|
return try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
}
|
||||||
|
|
||||||
var tokenExpiresAt: Date {
|
var tokenExpiresAt: Date {
|
||||||
get {
|
get {
|
||||||
// Tokens can expire and expire_in field is used to determine when:
|
// Tokens can expire and expire_in field is used to determine when:
|
||||||
@@ -22,8 +42,8 @@ struct TokenResponse: Decodable {
|
|||||||
// >a token should never be returned with less than 60 seconds to live.
|
// >a token should never be returned with less than 60 seconds to live.
|
||||||
//
|
//
|
||||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||||
|
|
||||||
creationTime + TimeInterval(expires_in ?? 60)
|
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +253,7 @@ class Registry {
|
|||||||
+ "while retrieving an authentication token", details: String(decoding: tokenResponseRaw, as: UTF8.self))
|
+ "while retrieving an authentication token", details: String(decoding: tokenResponseRaw, as: UTF8.self))
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAuthToken = try JSONDecoder().decode(TokenResponse.self, from: tokenResponseRaw)
|
currentAuthToken = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct RemoteName: Comparable, CustomStringConvertible {
|
|||||||
init(_ name: String) throws {
|
init(_ name: String) throws {
|
||||||
let csNormal = [
|
let csNormal = [
|
||||||
UInt8(ascii: "a")...UInt8(ascii: "z"),
|
UInt8(ascii: "a")...UInt8(ascii: "z"),
|
||||||
|
UInt8(ascii: "A")...UInt8(ascii: "Z"),
|
||||||
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||||
].asCharacterSet().union(CharacterSet(charactersIn: "_-."))
|
].asCharacterSet().union(CharacterSet(charactersIn: "_-."))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Root: AsyncParsableCommand {
|
struct Root: AsyncParsableCommand {
|
||||||
@@ -16,4 +17,27 @@ struct Root: AsyncParsableCommand {
|
|||||||
Push.self,
|
Push.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
public static func main() async throws {
|
||||||
|
// Handle cancellation by Ctrl+C
|
||||||
|
let task = withUnsafeCurrentTask { $0 }!
|
||||||
|
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||||
|
sigintSrc.setEventHandler {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
sigintSrc.activate()
|
||||||
|
|
||||||
|
// Parse and run command
|
||||||
|
do {
|
||||||
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
|
try await asyncCommand.run()
|
||||||
|
} else {
|
||||||
|
try command.run()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
exit(withError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -140,7 +140,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sema.wait()
|
await withTaskCancellationHandler(operation: {
|
||||||
|
sema.wait()
|
||||||
|
}, onCancel: {
|
||||||
|
sema.signal()
|
||||||
|
})
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
Task {
|
||||||
|
try await self.virtualMachine.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
||||||
@@ -173,6 +185,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
]
|
]
|
||||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||||
|
|
||||||
|
// Audio
|
||||||
|
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||||
|
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||||
|
configuration.audioDevices = [soundDeviceConfiguration]
|
||||||
|
|
||||||
// Keyboard and mouse
|
// Keyboard and mouse
|
||||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||||
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
@@ -207,7 +224,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, networkDevice: VZNetworkDevice, attachmentWasDisconnectedWithError error: Error) {
|
func virtualMachine(_ virtualMachine: VZVirtualMachine, networkDevice: VZNetworkDevice, attachmentWasDisconnectedWithError error: Error) {
|
||||||
print("virtual machine's network attachment has been disconnected")
|
print("virtual machine's network attachment \(networkDevice) has been disconnected with error: \(error)")
|
||||||
sema.signal()
|
sema.signal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ class VMStorageLocal {
|
|||||||
return try FileManager.default.contentsOfDirectory(
|
return try FileManager.default.contentsOfDirectory(
|
||||||
at: baseURL,
|
at: baseURL,
|
||||||
includingPropertiesForKeys: [.isDirectoryKey],
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
options: .skipsSubdirectoryDescendants).map { url in
|
options: .skipsSubdirectoryDescendants).compactMap { url in
|
||||||
let vmDir = VMDirectory(baseURL: url)
|
let vmDir = VMDirectory(baseURL: url)
|
||||||
|
|
||||||
|
if !vmDir.initialized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return (vmDir.name, vmDir)
|
return (vmDir.name, vmDir)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ final class RemoteNameTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testComplexTag() throws {
|
||||||
|
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "1.2.3-RC-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
|
||||||
|
}
|
||||||
|
|
||||||
func testDigest() throws {
|
func testDigest() throws {
|
||||||
let expectedRemoteName = RemoteName(
|
let expectedRemoteName = RemoteName(
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class TokenResponseTests: XCTestCase {
|
||||||
|
func testBasic() throws {
|
||||||
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
|
XCTAssertEqual(tokenResponse.token, "some token")
|
||||||
|
|
||||||
|
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||||
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
|
XCTAssertTrue(tokenResponse.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExpirationBasic() throws {
|
||||||
|
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||||
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
|
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||||
|
|
||||||
|
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||||
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
|
XCTAssertTrue(tokenResponse.isValid)
|
||||||
|
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
||||||
|
XCTAssertFalse(tokenResponse.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExpirationWithIssuedAt() throws {
|
||||||
|
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":3600,\"issued_at\":\"1970-01-01T00:00:00Z\"}".utf8)
|
||||||
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
|
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
||||||
|
XCTAssertFalse(tokenResponse.isValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user