1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Fly.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const CUSTOM_HOSTNAME = "alchemy-fly-cert-1.example.com";
19const REPLACE_HOSTNAME = "alchemy-fly-cert-2.example.com";
20
21/**
22 * Self-signed RSA-2048 certificates generated once with openssl and checked
23 * in so every test run uploads identical PEMs (never generated at test time):
24 *
25 * ```sh
26 * openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \
27 * -days 3650 -subj "/CN=alchemy-fly-cert-1.example.com/O=Alchemy/C=US" \
28 * -addext "subjectAltName=DNS:alchemy-fly-cert-1.example.com"
29 * ```
30 */
31const CERT_1A = `-----BEGIN CERTIFICATE-----
32MIIDQDCCAiigAwIBAgIJALfOoslKUT5HMA0GCSqGSIb3DQEBCwUAMEgxJzAlBgNV
33BAMMHmFsY2hlbXktZmx5LWNlcnQtMS5leGFtcGxlLmNvbTEQMA4GA1UECgwHQWxj
34aGVteTELMAkGA1UEBhMCVVMwHhcNMjYwODE5MDczMzA4WhcNMzYwODE2MDczMzA4
35WjBIMScwJQYDVQQDDB5hbGNoZW15LWZseS1jZXJ0LTEuZXhhbXBsZS5jb20xEDAO
36BgNVBAoMB0FsY2hlbXkxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOC
37AQ8AMIIBCgKCAQEAw94Wjab6qnzkDDoZgnzKUSoszHvkO4oTRio+dLHWaN6T1nCe
38YaoWlzjBVzSnpyHlKmCjzyYlLFbWdNxuyeqr8js0/pfQSCDAJetvWJCwHqcDxYV9
39fOxjNcbRyK22HcQW/iaqDFBmTb9HPp2zyH7smuFDgS6zEFWintoRtkizqAhdkl3c
40hI9soUK5jVoOK9f9xRbGMGf8JBuBevmzdZ2kosp346FuV3XCxzsvVcx3ub+QN0Gg
41K2MGW+c5gwGmltOmycdMpATIwrv7RtVH51cEjLHVGnySlMnmITDdtMnd2dlMjShe
42hBcR/Wn49iGz5y6mMz1s5NN7TjtzmmYJcx3sCQIDAQABoy0wKzApBgNVHREEIjAg
43gh5hbGNoZW15LWZseS1jZXJ0LTEuZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQAD
44ggEBAFMH+KMuoyc4z8TXpir+qptIrnqNb1klioxg8zpyvslXw5vmOjP+u6WXN3rK
459f1YqONz77+h0QO2vx/gBYmx1Ax0e/xfIw0b1T+HOXCjk5Z9EL4zxx4oiotZltza
46meWmuIDNuIkEbmQJZ55ET3UFtjQOkJCPXFTye9imB/jAQplLpFEL7JXP4jbqD64Y
47Cr+R+xfzKk1vlOLj4a36atZeLrvqz176jUC/KFHjvUaa+vW5FByoWtOb2FKbdBC7
48uebgS7SNRiqw2w7FbvQW9VsviOA7s7ZPsI6yB2O926zm7lrxXMQUXpaL+O6bXx0m
49uU0bCHvkCBRzA+ocsBEH7sxAgI8=
50-----END CERTIFICATE-----
51`;
52
53const KEY_1A = `-----BEGIN PRIVATE KEY-----
54MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDD3haNpvqqfOQM
55OhmCfMpRKizMe+Q7ihNGKj50sdZo3pPWcJ5hqhaXOMFXNKenIeUqYKPPJiUsVtZ0
563G7J6qvyOzT+l9BIIMAl629YkLAepwPFhX187GM1xtHIrbYdxBb+JqoMUGZNv0c+
57nbPIfuya4UOBLrMQVaKe2hG2SLOoCF2SXdyEj2yhQrmNWg4r1/3FFsYwZ/wkG4F6
58+bN1naSiynfjoW5XdcLHOy9VzHe5v5A3QaArYwZb5zmDAaaW06bJx0ykBMjCu/tG
591UfnVwSMsdUafJKUyeYhMN20yd3Z2UyNKF6EFxH9afj2IbPnLqYzPWzk03tOO3Oa
60ZglzHewJAgMBAAECggEACmEtjsonhHtj1mYJzglw60Yx45A5MxKJHPHGJ4b5Fsuc
61yANl0UUjN1ZRoJ5wuAGq1EkUxTh/Rc9ARCceU+L0w7xxfYBsEDZ0GE5WsznPq4As
62Sf5d/Q3F4CauHVVfTkqC8Wr5Hffww/P8AYx354saXMbNPf5MjPQMzyA9SgymQKRB
63Ca88cZRTSKAO1exTCLas8Lc5d22uJADKhCivjZzRt2eKf3bX2Kr0N89M76QDVWIV
64jcCDHxZ2yd2nkS8N9S8kHJQatmc5/YRhcCqfuJSrRNq/vUXKAL7od/PHiB/s+fe8
65mboPovuElV6Qhwea8wuLDKpmZpPz9vxyNkDeVyhVhQKBgQDyvumG5lrn9iuDTFaf
66MrvQGLzoIhu6CnRJQhMjOM8zfa+h74Ej9bZFwzIAv1abs0rkdn7/Lw7Y4+14y4V0
67V8JZ09RMStLCCMa4se30P7+H6P4sVRvgIo7ACSNyJ5AyotgCXGUQw+rU4Ew0B92o
68oKuyPzzjiSV1MB2t0LehnHzPywKBgQDOj+odrz/hQOvDC1bzpqX4f5iVG2hfwWzz
69AsgRbojCp0KlrBW6Fz2LHLDk9EyCXON2xDhIHnzgHvkhleaNMFFoZu7aIOJjjE2k
70uZt8ZusDWHDoFmvhgijHYo50CRm+iP5MqZnZ330h2UpyLsDpsFMuUTbIh86QeoUs
71287xPBSQ+wKBgQCYM+Qw1VbCgrOdy8u0Xhcsz5YC5wADknJ/TJK7Tu98FZ5+JrIO
72Xg4/h7heh5pCXhTjUvkl/9eLXr6TMukmnbAaqps+itvDFcWkIMxWjXIGQay9F1A3
73JPPkrNYwyWW8miZetJgZ/v3LJCgjp5rwFG4TgVsoP+HgrJ6vUWMSThBkKQKBgCUP
74/3LKEg0pp+O8MiLPoPIAevEuFMExpInJ0voFujYq6rNtOAzGxL0kLb029E9juVCD
75DpIqHj/cbtkO22oz5Dd2WJ78zYINF5VZ1EMy+DIGeWO7OiohP43e6i55v4vHatF4
76kOldx0b+hPQN3YFQqOwjmE/Mxkx4H1MYMX5pSEtnAoGAVh/vrfIAm6BlnHN8bOEy
77guJG67NWTGleZd/z3dsyjqZr/TKsUR/qnsvN+uIJF6xB8CAGrwGuPcPk2p+91CKP
78zGSvSBL56mSeLh7EWOVPkGBWxavEcZagZ6ehppK/8BjVPnzR4B2d+vh+WBY9uJMH
79XBcpU0H7jtbDMicqxDGkEo4=
80-----END PRIVATE KEY-----
81`;
82
83const CERT_1B = `-----BEGIN CERTIFICATE-----
84MIIDQDCCAiigAwIBAgIJAKGWJJtQyhFkMA0GCSqGSIb3DQEBCwUAMEgxJzAlBgNV
85BAMMHmFsY2hlbXktZmx5LWNlcnQtMS5leGFtcGxlLmNvbTEQMA4GA1UECgwHQWxj
86aGVteTELMAkGA1UEBhMCVVMwHhcNMjYwODE5MDczMzA4WhcNMzYwODE2MDczMzA4
87WjBIMScwJQYDVQQDDB5hbGNoZW15LWZseS1jZXJ0LTEuZXhhbXBsZS5jb20xEDAO
88BgNVBAoMB0FsY2hlbXkxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOC
89AQ8AMIIBCgKCAQEA1H+Zm+wZgrZXb6yMUBCtLXUFxAiQwtrW3CS+pFiRKqUAF/NW
90bUzu1ksugXhsFFiLh0fCjsk5DEUh9Slix18pI/OvJM6Ae5SONl5QAwzirAwTzQLq
91TFI2d2HTktF/RSuvM5gSI2xS0Fcpgh277AIuT4+UYQY7lt2sxpMt8z/77ir0kJEC
92+Kdn7h+YeFJwS7fQM4vy6onilQR1hwQIITh8oAvabygorRfKirw23DFZv6/OM5Dq
93L8oR9G1N1C07cBCEbyWvJXcSB1hPnpIMSW3DrB4zvUNukuxcyYPbN2jTbYCm2vzU
94UP+yYCSxE/MetpkJsBcf9pQa29/PF9Sx1+R2HQIDAQABoy0wKzApBgNVHREEIjAg
95gh5hbGNoZW15LWZseS1jZXJ0LTEuZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQAD
96ggEBAJvY/S7A1fI+zuvzIgmYOnku9f9VEu/fVXyNI6ZJHj1g7sF69H30R3VwRJGe
970OaVRxrjcBf/WikVXMrevkb14xmWVGPyJY86lUE5ofF/H/FL/zHQ2KBGJqZdffug
98LZydgkFHL7RX1Bo7kbQbIG0SithWnV2Gibx0QnW/HHh2OjOuYP9jSGFL4zYiVjRq
99qIPeyOUwvVUDZcYYKTIVt2EMj5jHZFH6yHnVbJnKYjlJIs7ywzK9/X24uGI49NpX
100knNTol8YVudJSO/V0DGsADj7kINIxs5GgwtQ0AUJvb+6P0iDVFxWVjTsF2JOf0zW
101TmEwSgr3XQioUtj36eJ8Puux8w4=
102-----END CERTIFICATE-----
103`;
104
105const KEY_1B = `-----BEGIN PRIVATE KEY-----
106MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUf5mb7BmCtldv
107rIxQEK0tdQXECJDC2tbcJL6kWJEqpQAX81ZtTO7WSy6BeGwUWIuHR8KOyTkMRSH1
108KWLHXykj868kzoB7lI42XlADDOKsDBPNAupMUjZ3YdOS0X9FK68zmBIjbFLQVymC
109HbvsAi5Pj5RhBjuW3azGky3zP/vuKvSQkQL4p2fuH5h4UnBLt9Azi/LqieKVBHWH
110BAghOHygC9pvKCitF8qKvDbcMVm/r84zkOovyhH0bU3ULTtwEIRvJa8ldxIHWE+e
111kgxJbcOsHjO9Q26S7FzJg9s3aNNtgKba/NRQ/7JgJLET8x62mQmwFx/2lBrb388X
1121LHX5HYdAgMBAAECggEBAK0ApaLX+Xz9zvnQVPvqV9OmbmdHQfQiDsNz9vH+WVGK
113vCTDrB2YgOpekyI9VGDMJeyhc7ikApoGvrrGUvFWfDwU7QbeSKRUMeP5rNKO7wx7
114WLOUKASy+jz/nCzv+tnp0t4CdOH6EwIjtFuGsbRXojSMBJVpaaJOH5gbw1FDSmx/
115eI1TRkwU7kHKOUkEoOHIrl1JFF5MiwtDIxYkQd6LmS1hL1oLBl4hNuUzlWVz6Utz
1162dP3iMNXHQ+brEw5C+v6YUk+t5goV3SIW/EKREpAIyw6zhXtGYTsPLzd6S0VrA7j
117NI6kS6YNVRWoZHxmZEJBhgvzGN2N4UtgIAg2DCHBjoECgYEA6eAfGT5zBG5IVemr
118x08u45dlf32ZNmKazr+LY/6+81x6ep5jc+DOUpe3muBg0gfxSCCZoMZpbIdr6STW
119Tl/b8JCfgJ5ZeJRAiFYNfpKkfxfCjJ0df02dsXxh4bHJ5kfPfoC5ndgpMeg0oR+c
1200TvtdIspoTrdqNBib5qtnc+BCTkCgYEA6JnHvaCp/C03uP5lXD7zSOTj0/uRzQj8
121eh0W1ucgZDMlwxk+nGFj8sjhSKq5BrGskr3X9lD8O4gGJ0CYnnfA5v2sKIubWKJw
122p1J3vwjZNaCXyfl2GgU8i0i0tKqb6GKNikyEYJY8LzImLEwHsJBFjqtmQ/INAZgG
123WRisYhZBiAUCgYAmWg1fe2ErGdac2AvGTFLZGYuYY4VLaNIQE1MNW8n+aGzhTLXs
124W7IZ0y1VpXPbHVhQxp/KwJ6rIG3uto66HXgYs+6kDdOINW97q/jch7mX6dKKcn1S
1250eyJHWeDtj7wl1gMW5mUq8zdlUGEJoX1vckLsoJqIRgWRc3OlWPhHBUB6QKBgQC3
126aNMsfqyrEYi30blhNK4nk3zmoZ8FOwnCzH8+878B46bqIVTSgKUMQ0QJRb1iPuWi
127TBonuxI5avfXKcNuaWVtfyeqTPMwv93uwkN0GmkUNU6bT57Fw81K0wjS0Rjg9B1B
128qTZU5wIMARJCqa0Cl7CRYCviddG1qQeQZ7k+GuiPdQKBgHlmMzKipMwFinvDyhNw
1296G8UKF+GRx1I4CnuEm6F8l+gguNQ4ufjeCpqepJQ0vQJ92UJtRdK2y5K3Wg7wl6l
1305/txHtxiDrOQtXHD6fZGOP9FeyUCzM15t+dskhLsnxBLbnSAbJ1+UG1rMXCGFt87
131FgKFMKpXLAf0maBRv4RowQtS
132-----END PRIVATE KEY-----
133`;
134
135const CERT_2 = `-----BEGIN CERTIFICATE-----
136MIIDQDCCAiigAwIBAgIJAKXks8DaxmKRMA0GCSqGSIb3DQEBCwUAMEgxJzAlBgNV
137BAMMHmFsY2hlbXktZmx5LWNlcnQtMi5leGFtcGxlLmNvbTEQMA4GA1UECgwHQWxj
138aGVteTELMAkGA1UEBhMCVVMwHhcNMjYwODE5MDczMzA5WhcNMzYwODE2MDczMzA5
139WjBIMScwJQYDVQQDDB5hbGNoZW15LWZseS1jZXJ0LTIuZXhhbXBsZS5jb20xEDAO
140BgNVBAoMB0FsY2hlbXkxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOC
141AQ8AMIIBCgKCAQEA7QHJUNwTdk3V5JlofytnMAubCB+iAclI6FLkCPS8tWekXPKD
142jx8Xp3pnl8dkMhGxEpNpZSAdH7GoWZFhcOLLnT3ZojkeN8xCnVfXqvjZplLPN/ie
143973shD2/cREVxRR/Bh6yc9NL528wSDEyNJ/lP5hdzNGSWiliE8jFaH3ymfeqhqYd
144V3ghVBtyzP6l+2bjksiP2Km7ks+u0Qe9Mme4/u3wnY9dcWSCtpj8DcfKF+fUtMQh
145RzD5TxWAV1ZZC8/bXqRzYWLbfHHVVDUE1wQI7fdEU4pwPiq70a9LyGKMlo1Upo+M
1464EwR2QKtAFjNz8wvMsCODoM+Buv8QtDlPKV4WQIDAQABoy0wKzApBgNVHREEIjAg
147gh5hbGNoZW15LWZseS1jZXJ0LTIuZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQAD
148ggEBAKIa9MMRCDU+W+HX+S04NWax2ixA0tO4tfQeeRJwbSxxCLaeUWK/J7Sb1avw
149iLHSxBcpMeSvN42rL0aoQtZzLuxKg+COg+frLM+1lCRf5lRZilknpFV8BUb5wWmb
150mGbNlU5z9hmkZ2cX/50IYLaQSEXrCVofq9KBev/ohA6VtT4WEd+to1aIP0h45MmT
151qT0+MGBT2OZVIC15tvm4K2Aw0psIkG1R+WxZDLnIIyGCN/3eHL6kAm3njXxGj+81
152K3xJ/mfJ6UgPvnuAbqeC+rHmWtFRbJpSI85HuFkvi60O+P1u77EjuSFmWFsLJrnp
153/qOFL9VsXMBN3vNu7u/0ZBUBUVI=
154-----END CERTIFICATE-----
155`;
156
157const KEY_2 = `-----BEGIN PRIVATE KEY-----
158MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDtAclQ3BN2TdXk
159mWh/K2cwC5sIH6IByUjoUuQI9Ly1Z6Rc8oOPHxenemeXx2QyEbESk2llIB0fsahZ
160kWFw4sudPdmiOR43zEKdV9eq+NmmUs83+J73veyEPb9xERXFFH8GHrJz00vnbzBI
161MTI0n+U/mF3M0ZJaKWITyMVoffKZ96qGph1XeCFUG3LM/qX7ZuOSyI/YqbuSz67R
162B70yZ7j+7fCdj11xZIK2mPwNx8oX59S0xCFHMPlPFYBXVlkLz9tepHNhYtt8cdVU
163NQTXBAjt90RTinA+KrvRr0vIYoyWjVSmj4zgTBHZAq0AWM3PzC8ywI4Ogz4G6/xC
1640OU8pXhZAgMBAAECggEBAI++eZ71G0ixRHz1Hg+i/16Aa3Kt3NBAiFt/ipZz+M/9
165IA7Pd0MilHIbJyFC6V4EpOSjS1Tt+TvzwCsypWqV4vBnRsvA4Lbux6guUrt7WmYp
16660wDGkcfhYde+/FTb2SsXa4UiB7/lBW1tg7S56RLJftAKgPDR4QvfTCKoFATs0P1
167az0u0cDhDQXSLoCIs+FXswMa/o1uvPyv2SR1yj3z6G5qth55u5mOXylDu1Se7r3y
168oM5WWV1zoSBbHNsHRu3UbOkm0stPyj2/01KU3LTuGDwlT9hfv+x4zsnTgo+CGOck
169vLNvAywiD8td7XxOEwEVaksSAJQ80ge+9Uv7nDWlCdECgYEA+rldCBZ+2E/BKRsV
170R9UkIi1iqwJxSR03hrU3SGbAW9S85JnIkqOcgJRtlhzMtlQ4Cym4tC/hEwcJtoZS
171YQyFCrrxwLwwGuKIReqVwoQgqpJrhKs3+eCT/ssHTnnufQ68ObZUTDe3/yd5W0MR
17245sw5ucdXasNsrJ5VIwgEZ0J6bcCgYEA8f6HnW9uT6UqhcFSlA6JUOzyQxKSwRBX
17315l2wqAGOytbPfxo0t88RNw0zDCMLLJW/h4nH14TIqsZvmPVgbci3aOl8iK4p6Cs
174rU6fPObzqyqWahB0uWxlvkV5xPh69lpPXgW4FHS2AJ/7LuT0K7l1xskFS0uygk82
175NcYXOwZ37m8CgYEAjzAVhWzzfC3wLr2c5a1AOZSZ6Pg4In1hHLmZnOuxp8BTMGrz
176NWvjETuzaiuAbhCjAR0Oszk03V0Dmw4TNgGkaYBiWKQhBPmiwes7JB+5WDDO3rG6
177AibT3ous3JCeNyWXMY6DjhO0LTAqCoi/SrFyuCv9hWUPKaLrz/FGz0hURPkCgYB/
178wZeJFzYOmCz91lUpUw9NiPpRTLHreRxW928KSv9fJ4GRFPumMDKaTAMXtZ2bTz+8
179AuE2nyzNBbWenQgv5iZjCq1mSsxfuuTOzg4lUexcdixYrH1jEwYX0If75A7OllkG
180U1CehAs+AzfRxw/dMZkrLB8+ZCVGxFjjRMQo5j5UwwKBgAL8u/8C5ZDlK9/h85NJ
181jQGt3JmtR2FDjlamV6wAese7LgmVt7PPz/Ou42cOFDBN/p9fheLhNOA0Vo4RX6Y0
182ikCU8TnSPOzNvJPwSZQnP0A4fbMGUlXa5q/cmbK1VeiD2f5FdJ+G4oIlRYuCxCgd
183onolH/5YrvTrrhSOZeUzS72j
184-----END PRIVATE KEY-----
185`;
186
187const waitUntilAppGone = (appName: string) =>
188 machines.getApp({ app_name: appName }).pipe(
189 Effect.as("found" as const),
190 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
191 Effect.repeat({
192 schedule: Schedule.spaced("1 second"),
193 until: (status) => status === "gone",
194 times: 10,
195 }),
196 );
197
198const waitUntilCertGone = (appName: string, hostname: string) =>
199 machines.getAppCertificate({ app_name: appName, hostname }).pipe(
200 Effect.as("found" as const),
201 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
202 Effect.repeat({
203 schedule: Schedule.spaced("1 second"),
204 until: (status) => status === "gone",
205 times: 10,
206 }),
207 );
208
209const deleteIfPresent = (appName: string, hostname: string) =>
210 machines
211 .deleteAppCertificate({ app_name: appName, hostname })
212 .pipe(Effect.catchTag("NotFound", () => Effect.void));
213
214test.provider(
215 "create, update, and delete a custom certificate",
216 (stack) =>
217 Effect.gen(function* () {
218 yield* stack.destroy();
219
220 const created = yield* stack.deploy(
221 Effect.gen(function* () {
222 const app = yield* Fly.App("CertApp");
223 const cert = yield* Fly.Certificate("Www", {
224 app,
225 hostname: CUSTOM_HOSTNAME,
226 kind: "custom",
227 fullchain: CERT_1A,
228 privateKey: KEY_1A,
229 });
230 return { app, cert };
231 }),
232 );
233
234 expect(created.cert.appName).toEqual(created.app.appName);
235 expect(created.cert.hostname).toEqual(CUSTOM_HOSTNAME);
236 expect(created.cert.source).toEqual("custom");
237 expect(created.cert.status).toEqual(expect.any(String));
238
239 const fetched = yield* machines.getAppCertificate({
240 app_name: created.app.appName,
241 hostname: CUSTOM_HOSTNAME,
242 });
243 expect(fetched.hostname).toEqual(CUSTOM_HOSTNAME);
244 expect(
245 fetched.certificates?.some((entry) => entry.source === "custom"),
246 ).toBe(true);
247
248 const updated = yield* stack.deploy(
249 Effect.gen(function* () {
250 const app = yield* Fly.App("CertApp");
251 const cert = yield* Fly.Certificate("Www", {
252 app,
253 hostname: CUSTOM_HOSTNAME,
254 kind: "custom",
255 fullchain: CERT_1B,
256 privateKey: KEY_1B,
257 });
258 return { app, cert };
259 }),
260 );
261
262 expect(updated.cert.hostname).toEqual(CUSTOM_HOSTNAME);
263 expect(updated.cert.source).toEqual("custom");
264 expect(updated.cert.appName).toEqual(created.app.appName);
265 expect(updated.app.appId).toEqual(created.app.appId);
266
267 const refetched = yield* machines.getAppCertificate({
268 app_name: updated.app.appName,
269 hostname: CUSTOM_HOSTNAME,
270 });
271 expect(refetched.hostname).toEqual(CUSTOM_HOSTNAME);
272 expect(
273 refetched.certificates?.some((entry) => entry.source === "custom"),
274 ).toBe(true);
275
276 yield* stack.destroy();
277
278 const certGone = yield* waitUntilCertGone(
279 created.app.appName,
280 CUSTOM_HOSTNAME,
281 );
282 expect(certGone).toEqual("gone");
283 const appGone = yield* waitUntilAppGone(created.app.appName);
284 expect(appGone).toEqual("gone");
285 }).pipe(logLevel),
286 {
287 tags: [
288 "provider:fly",
289 "provider:fly:app",
290 "provider:fly:certificate",
291 "provider:fly:machine",
292 "live",
293 ],
294 timeout: 120_000,
295 },
296);
297
298test.provider(
299 "replace when hostname changes",
300 (stack) =>
301 Effect.gen(function* () {
302 yield* stack.destroy();
303
304 const created = yield* stack.deploy(
305 Effect.gen(function* () {
306 const app = yield* Fly.App("CertReplaceApp");
307 const cert = yield* Fly.Certificate("Www", {
308 app,
309 hostname: CUSTOM_HOSTNAME,
310 kind: "custom",
311 fullchain: CERT_1A,
312 privateKey: KEY_1A,
313 });
314 return { app, cert };
315 }),
316 );
317
318 expect(created.cert.hostname).toEqual(CUSTOM_HOSTNAME);
319
320 const replaced = yield* stack.deploy(
321 Effect.gen(function* () {
322 const app = yield* Fly.App("CertReplaceApp");
323 const cert = yield* Fly.Certificate("Www", {
324 app,
325 hostname: REPLACE_HOSTNAME,
326 kind: "custom",
327 fullchain: CERT_2,
328 privateKey: KEY_2,
329 });
330 return { app, cert };
331 }),
332 );
333
334 expect(replaced.cert.hostname).toEqual(REPLACE_HOSTNAME);
335 expect(replaced.cert.hostname).not.toEqual(created.cert.hostname);
336 expect(replaced.cert.source).toEqual("custom");
337 expect(replaced.cert.appName).toEqual(created.app.appName);
338
339 const fetched = yield* machines.getAppCertificate({
340 app_name: replaced.app.appName,
341 hostname: REPLACE_HOSTNAME,
342 });
343 expect(fetched.hostname).toEqual(REPLACE_HOSTNAME);
344
345 const oldGone = yield* waitUntilCertGone(
346 created.app.appName,
347 CUSTOM_HOSTNAME,
348 );
349 expect(oldGone).toEqual("gone");
350
351 yield* stack.destroy();
352
353 const certGone = yield* waitUntilCertGone(
354 replaced.app.appName,
355 REPLACE_HOSTNAME,
356 );
357 expect(certGone).toEqual("gone");
358 const appGone = yield* waitUntilAppGone(replaced.app.appName);
359 expect(appGone).toEqual("gone");
360 }).pipe(logLevel),
361 {
362 tags: [
363 "provider:fly",
364 "provider:fly:app",
365 "provider:fly:certificate",
366 "provider:fly:machine",
367 "live",
368 ],
369 timeout: 120_000,
370 },
371);
372
373test.provider(
374 "list enumerates the deployed certificate",
375 (stack) =>
376 Effect.gen(function* () {
377 yield* stack.destroy();
378
379 const deployed = yield* stack.deploy(
380 Effect.gen(function* () {
381 const app = yield* Fly.App("CertListApp");
382 const cert = yield* Fly.Certificate("Www", {
383 app,
384 hostname: CUSTOM_HOSTNAME,
385 kind: "custom",
386 fullchain: CERT_1A,
387 privateKey: KEY_1A,
388 });
389 return { app, cert };
390 }),
391 );
392
393 const provider = yield* Provider.findProvider(Fly.Certificate);
394 const all = yield* provider.list();
395 const found = all.find(
396 (row) =>
397 row.appName === deployed.app.appName &&
398 row.hostname === CUSTOM_HOSTNAME,
399 );
400 expect(found).toBeDefined();
401 expect(found?.source).toEqual("custom");
402
403 yield* stack.destroy();
404
405 const certGone = yield* waitUntilCertGone(
406 deployed.app.appName,
407 CUSTOM_HOSTNAME,
408 );
409 expect(certGone).toEqual("gone");
410 const appGone = yield* waitUntilAppGone(deployed.app.appName);
411 expect(appGone).toEqual("gone");
412 }).pipe(logLevel),
413 {
414 tags: [
415 "provider:fly",
416 "provider:fly:app",
417 "provider:fly:certificate",
418 "provider:fly:machine",
419 "live",
420 ],
421 timeout: 120_000,
422 },
423);
424
425test.provider(
426 "ACME create is rejected without a hostname",
427 (stack) =>
428 Effect.gen(function* () {
429 yield* stack.destroy();
430
431 const app = yield* stack.deploy(
432 Effect.gen(function* () {
433 return yield* Fly.App("AcmeProbeApp");
434 }),
435 );
436
437 const result = yield* Effect.result(
438 machines.createAppAcmeCertificate({
439 app_name: app.appName,
440 }),
441 );
442
443 if (Result.isFailure(result)) {
444 expect(["BadRequest", "UnprocessableEntity"]).toContain(
445 result.failure._tag,
446 );
447 } else {
448 const hostname = result.success.hostname;
449 if (hostname !== undefined && hostname.length > 0) {
450 yield* deleteIfPresent(app.appName, hostname);
451 }
452 expect(result.success.hostname).toBeUndefined();
453 }
454
455 yield* stack.destroy();
456
457 const gone = yield* waitUntilAppGone(app.appName);
458 expect(gone).toEqual("gone");
459 }).pipe(logLevel),
460 {
461 tags: [
462 "provider:fly",
463 "provider:fly:app",
464 "provider:fly:certificate",
465 "provider:fly:machine",
466 "live",
467 ],
468 timeout: 120_000,
469 },
470);