import CompressNIO import Configuration import Foundation import Hummingbird import HummingbirdTesting import Infrastructure import NIOCore import Testing @testable import Website @testable import WebsiteLibrary @Suite("App executable") struct AppTests { // MARK: Constants // Referenced through fingerprinted URLs, or an immutable subset file in the case of a font, so all of these are served immutable. private let immutableExtensions: [AssetExtension] = [ .css, .jpg, .js, .mp4, .webp, .woff2 ] // Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle // at build time — the repository tree itself is off limits to Xcode's test runner. private let staticFilesPath: String = { guard let url = Bundle.module.url( forResource: "Static", withExtension: nil ) else { preconditionFailure("The static files are missing from the test bundle.") } return url.path }() // MARK: Functional tests @Test func `landing page to be served at root`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in let body = String(buffer: response.body) #expect(response.status == .ok) #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(body.contains("Back issues, from shops you can vet first.")) } } } @Test func `landing page to list the seeded catalogue`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in let body = String(buffer: response.body) #expect(body.contains("New in single issues")) #expect(body.contains(#"href="/listing/nightward-1-cgc-9-8""#)) } } } @Test(arguments: [ "/search?q=nightward", "/listing/nightward-1-cgc-9-8", "/shop/kestrel-row-comics", ]) func `catalogue page to be served with the security headers`( uri: String ) async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: uri, method: .get ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(response.headers[.contentSecurityPolicy] != nil) } } } @Test func `sign-in to start a session the other pages recognize`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in let cookie = try await client.execute( uri: "/sign-in", method: .post, headers: [.contentType: "application/x-www-form-urlencoded"], body: ByteBuffer(string: "return=/listing/nightward-1-cgc-9-8") ) { response in #expect(response.status == .seeOther) #expect(response.headers[.location] == "/listing/nightward-1-cgc-9-8") return String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) } try await client.execute( uri: "/listing/nightward-1-cgc-9-8", method: .get, headers: [.cookie: cookie] ) { response in let body = String(buffer: response.body) #expect(body.contains(#"aria-label="Account of Mara Visser""#)) #expect(body.contains(#"href="/listing/nightward-1-cgc-9-8/offer""#)) } } } @Test func `buying to open a sale record for its buyer alone`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"] let cookie = try await client.execute( uri: "/sign-in", method: .post, headers: form, body: ByteBuffer(string: "return=/") ) { response in String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) } var signedIn = form signedIn[.cookie] = cookie let sale = try await client.execute( uri: "/listing/echo-country-3/buy", method: .post, headers: signedIn ) { response in #expect(response.status == .seeOther) return try #require(response.headers[.location]) } try await client.execute( uri: sale, method: .get, headers: [.cookie: cookie] ) { response in #expect(response.status == .ok) #expect(String(buffer: response.body).contains("Awaiting payment")) } try await client.execute( uri: sale, method: .get ) { response in #expect(response.status == .found) } } } @Test func `landing page to catch up once a copy is sold`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in let sold = #"href="/listing/echo-country-3""# let before = try await client.execute(uri: "/", method: .get) { response in #expect(String(buffer: response.body).contains(sold)) return try #require(response.headers[.eTag]) } let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"] let cookie = try await client.execute(uri: "/sign-in", method: .post, headers: form, body: ByteBuffer(string: "return=/")) { response in String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) } var signedIn = form signedIn[.cookie] = cookie try await client.execute(uri: "/listing/echo-country-3/buy", method: .post, headers: signedIn) { response in #expect(response.status == .seeOther) } try await client.execute(uri: "/", method: .get, headers: [.ifNoneMatch: before]) { response in // The page changed, so the entity tag a browser holds no longer revalidates. #expect(response.status == .ok) #expect(response.headers[.eTag] != before) #expect(!String(buffer: response.body).contains(sold)) } } } @Test func `error page to be served for an unknown listing`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/listing/missing", method: .get ) { response in #expect(response.status == .notFound) #expect(String(buffer: response.body).contains("Page Not Found")) } } } @Test func `landing page to answer a head request`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .head ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(response.body.readableBytes == 0) } } } @Test func `health check to be served at the health path`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/health", method: .get ) { response in let body = String(buffer: response.body) #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/json") #expect(body == #"{"status":"ok"}"#) } } } @Test func `readiness check to be served at the readiness path`() async throws { // Live mode runs the application's service group, so the `Fluent` service starts before the // request and shuts its connection pool down after — the router-only mode never would. try await app( staticFilesPath: staticFilesPath ).test(.live) { client in try await client.execute( uri: "/health/ready", method: .get ) { response in let body = String(buffer: response.body) #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/json") #expect(body == #"{"status":"ready"}"#) } } } @Test(arguments: StaticFile.all) func `static files to be served`( staticFile file: StaticFile ) async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in for fileExtension in file.fileExtensions { try await client.execute( uri: "/\(file.relativePath(for: fileExtension))", method: .get ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == fileExtension.contentType) let cacheControl = try #require(response.headers[.cacheControl]) #expect(cacheControl.contains("public") == true) #expect(cacheControl.contains("max-age=") == true) if immutableExtensions.contains(fileExtension) { #expect(cacheControl.contains("immutable") == true) } else if fileExtension == .txt { #expect(cacheControl.contains("must-revalidate") == true) } } } } } @Test func `versioned asset URL to be served`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/css/shared.css?v=0123456789abcdef", method: .get ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "text/css") } } } @Test func `landing page to reference fingerprinted assets`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in let body = String(buffer: response.body) #expect(body.contains("/css/shared.css?v=")) #expect(body.contains("/js/shared.js?v=")) } } } @Test func `landing page to revalidate with an entity tag`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in let eTag = try await client.execute( uri: "/", method: .get ) { response in #expect(response.headers[.cacheControl] == "public, no-cache") return try #require(response.headers[.eTag]) } try await client.execute( uri: "/", method: .get, headers: [.ifNoneMatch: eTag] ) { response in #expect(response.status == .notModified) #expect(response.body.readableBytes == 0) #expect(response.headers[.eTag] == eTag) } } } @Test func `responses to vary on language and encoding`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in let vary = try #require(response.headers[.vary]) #expect(vary.contains("Accept-Language")) #expect(vary.contains("Accept-Encoding")) } } } @Test func `response to be compressed when the client supports it`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get, headers: [.acceptEncoding: "gzip"] ) { response in #expect(response.status == .ok) #expect(response.headers[.contentEncoding] == "gzip") } } } @Test func `response to not be compressed when the client does not support it`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in #expect(response.status == .ok) #expect(response.headers[.contentEncoding] == nil) } } } /// `CompressionMiddleware` stands in for Hummingbird's `ResponseCompressionMiddleware`, which appends to `Content-Encoding` without /// checking for one: a pre-compressed page would ship as `gzip, gzip`. Decoding makes it visible — a doubly compressed page decodes once, /// into bytes that are not HTML. @Test func `page to be served pre-compressed, and only once`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get, headers: [.acceptEncoding: "gzip, deflate, br"] ) { response in #expect(response.status == .ok) #expect(response.headers[values: .contentEncoding] == ["gzip"]) var body = response.body let decoded = try body.decompress(with: .gzip()) #expect(String(buffer: decoded).hasPrefix("")) // Complete before the first byte is written, so it is sized rather than chunked. #expect(response.headers[.contentLength] == String(response.body.readableBytes)) #expect(response.body.readableBytes < decoded.readableBytes) } } } /// The compressed copy is only handed to a client that asked for it; everyone else gets the rendered bytes. @Test func `page to be served uncompressed when gzip is not accepted`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get, headers: [.acceptEncoding: "identity"] ) { response in #expect(response.status == .ok) #expect(response.headers[.contentEncoding] == nil) #expect(String(buffer: response.body).hasPrefix("")) } } } @Test func `error page to be served when not found`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/this-path-does-not-exist", method: .get ) { response in let body = String(buffer: response.body) #expect(response.status == .notFound) #expect(response.headers[.contentType] == "text/html; charset=utf-8") #expect(body.contains("Page Not Found")) } } } @Test func `error page to reference fingerprinted assets`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/this-path-does-not-exist", method: .get ) { response in let body = String(buffer: response.body) #expect(body.contains("/css/not-found.css?v=")) #expect(body.contains("/js/shared.js?v=")) } } } @Test func `error page to be served without revalidation headers`() async throws { // A `304 Not Modified` only ever stands in for a success, so the error page must not // invite revalidation with an entity tag or a cache policy. try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/this-path-does-not-exist", method: .get ) { response in #expect(response.status == .notFound) #expect(response.headers[.eTag] == nil) #expect(response.headers[.cacheControl] == nil) } } } @Test func `error page to vary on language and encoding`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/this-path-does-not-exist", method: .get ) { response in let vary = try #require(response.headers[.vary]) #expect(vary.contains("Accept-Language")) #expect(vary.contains("Accept-Encoding")) } } } @Test func `landing page to revalidate a conditional head request`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in let eTag = try await client.execute( uri: "/", method: .get ) { response in try #require(response.headers[.eTag]) } try await client.execute( uri: "/", method: .head, headers: [.ifNoneMatch: eTag] ) { response in #expect(response.status == .notModified) #expect(response.body.readableBytes == 0) } } } @Test func `security headers to be applied to the landing page`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in #expect(response.status == .ok) #expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy) #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions) #expect(response.headers[.frameOptions] == String.Security.frameOptions) #expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy) #expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy) #expect(response.headers[.strictTransportSecurity] == nil) } } } @Test func `security headers to be applied to the error page`() async throws { try await app( staticFilesPath: staticFilesPath ).test(.router) { client in try await client.execute( uri: "/this-path-does-not-exist", method: .get ) { response in #expect(response.status == .notFound) #expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy) #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions) } } } @Test func `strict-transport-security to be applied when configured`() async throws { try await app( staticFilesPath: staticFilesPath, strictTransportSecurity: "max-age=31536000; includeSubDomains" ).test(.router) { client in try await client.execute( uri: "/", method: .get ) { response in #expect(response.status == .ok) #expect(response.headers[.strictTransportSecurity] == "max-age=31536000; includeSubDomains") } } } } // MARK: - Helpers private extension AppTests { // MARK: Methods func app( staticFilesPath: String, strictTransportSecurity: String? = nil ) async throws -> some ApplicationProtocol { try await application( reader: reader( staticFilesPath: staticFilesPath, strictTransportSecurity: strictTransportSecurity ) ) } func reader( staticFilesPath: String, strictTransportSecurity: String? = nil ) -> ConfigReader { ConfigReader(providers: [{ if let strictTransportSecurity { InMemoryProvider(values: [ .HTTP.host: "127.0.0.1", .HTTP.port: "0", .Log.level: "trace", .Path.staticFiles: .init(stringLiteral: staticFilesPath), .Security.strictTransportSecurity: .init(stringLiteral: strictTransportSecurity) ]) } else { InMemoryProvider(values: [ .HTTP.host: "127.0.0.1", .HTTP.port: "0", .Log.level: "trace", .Path.staticFiles: .init(stringLiteral: staticFilesPath), ]) } }()]) } }