|
| 1 | +package api |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "context" |
| 6 | + "crypto/ecdsa" |
| 7 | + "crypto/elliptic" |
| 8 | + "crypto/rand" |
| 9 | + "crypto/x509" |
| 10 | + "crypto/x509/pkix" |
| 11 | + "database/sql" |
| 12 | + "encoding/json" |
| 13 | + "encoding/pem" |
| 14 | + "io/fs" |
| 15 | + "math/big" |
| 16 | + "mime/multipart" |
| 17 | + "net/http" |
| 18 | + "net/http/httptest" |
| 19 | + "os" |
| 20 | + "path/filepath" |
| 21 | + "testing" |
| 22 | + "time" |
| 23 | + |
| 24 | + "github.com/go-chi/chi/v5" |
| 25 | + |
| 26 | + argoscerts "github.com/cmos486/argos-edge/backend/internal/certs" |
| 27 | + "github.com/cmos486/argos-edge/backend/internal/crypto" |
| 28 | + argosdb "github.com/cmos486/argos-edge/backend/internal/db" |
| 29 | + argosmigrations "github.com/cmos486/argos-edge/backend/migrations" |
| 30 | + "github.com/cmos486/argos-edge/backend/internal/models" |
| 31 | +) |
| 32 | + |
| 33 | +// hooksForMigrate adapts the migrations package's hooks to the db |
| 34 | +// package's Hook signature for test-time migration runs. |
| 35 | +func hooksForMigrate() map[string]argosdb.Hook { |
| 36 | + m := make(map[string]argosdb.Hook, len(argosmigrations.UpHooks)) |
| 37 | + for k, v := range argosmigrations.UpHooks { |
| 38 | + m[k] = argosdb.Hook(v) |
| 39 | + } |
| 40 | + return m |
| 41 | +} |
| 42 | + |
| 43 | +// newUploadTestHandlers builds a Handlers backed by a :memory: DB |
| 44 | +// with every migration applied, a working Cipher, a temp-dir-backed |
| 45 | +// ManualCertStore, and one seed host that the uploaded cert will |
| 46 | +// attach to. Returns the handlers, the DB, the temp dir, and the |
| 47 | +// host ID. |
| 48 | +func newUploadTestHandlers(t *testing.T) (*Handlers, *sql.DB, string, int64) { |
| 49 | + t.Helper() |
| 50 | + d, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)") |
| 51 | + if err != nil { |
| 52 | + t.Fatal(err) |
| 53 | + } |
| 54 | + t.Cleanup(func() { _ = d.Close() }) |
| 55 | + |
| 56 | + ctx := context.Background() |
| 57 | + if err := argosdb.Migrate(ctx, d, fs.FS(argosmigrations.FS), hooksForMigrate()); err != nil { |
| 58 | + t.Fatalf("migrate: %v", err) |
| 59 | + } |
| 60 | + |
| 61 | + // Seed a user so the FK on uploaded_by is satisfied. The |
| 62 | + // UploadManualCert handler defaults uploaded_by to 0 when the |
| 63 | + // request has no session context, so id=0 must exist OR we bypass |
| 64 | + // the FK by seeding id=1 and pretending a user is "present" via |
| 65 | + // the session context. Since tests don't run through the auth |
| 66 | + // middleware, we seed id=0 directly. |
| 67 | + if _, err := d.Exec(`INSERT INTO users(id, username, password_hash) VALUES(0, 'test', '')`); err != nil { |
| 68 | + t.Fatal(err) |
| 69 | + } |
| 70 | + |
| 71 | + // Seed a target group + one host (tls_mode=auto -> will flip to |
| 72 | + // manual via UploadManualCert's side-effect). |
| 73 | + if _, err := d.Exec( |
| 74 | + `INSERT INTO target_groups(name, protocol, algorithm) VALUES('t', 'http', 'round_robin')`, |
| 75 | + ); err != nil { |
| 76 | + t.Fatal(err) |
| 77 | + } |
| 78 | + res, err := d.Exec( |
| 79 | + `INSERT INTO hosts(domain, target_group_id, tls_mode, tls_email, enabled) |
| 80 | + VALUES(?, 1, 'auto', 'ops@example.com', 1)`, |
| 81 | + "example.com", |
| 82 | + ) |
| 83 | + if err != nil { |
| 84 | + t.Fatal(err) |
| 85 | + } |
| 86 | + // Phase 9: host_security is seeded by the CreateHost flow; here |
| 87 | + // we go direct-SQL so the FK from UpdateHost doesn't trip up. |
| 88 | + hostID, _ := res.LastInsertId() |
| 89 | + if _, err := d.Exec(`INSERT INTO host_security(host_id) VALUES(?)`, hostID); err != nil { |
| 90 | + t.Fatal(err) |
| 91 | + } |
| 92 | + |
| 93 | + cipher, err := crypto.New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") |
| 94 | + if err != nil { |
| 95 | + t.Fatal(err) |
| 96 | + } |
| 97 | + dir := t.TempDir() |
| 98 | + h := &Handlers{ |
| 99 | + DB: d, |
| 100 | + Cipher: cipher, |
| 101 | + ManualCertStore: &argoscerts.Store{Dir: dir}, |
| 102 | + } |
| 103 | + return h, d, dir, hostID |
| 104 | +} |
| 105 | + |
| 106 | +// genTestCert mints a self-signed ECDSA cert + key covering the given |
| 107 | +// SANs. Small enough to keep in the test file. |
| 108 | +func genTestCert(t *testing.T, cn string, sans []string) (certPEM, keyPEM string) { |
| 109 | + t.Helper() |
| 110 | + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 111 | + if err != nil { |
| 112 | + t.Fatal(err) |
| 113 | + } |
| 114 | + tmpl := &x509.Certificate{ |
| 115 | + SerialNumber: big.NewInt(time.Now().UnixNano()), |
| 116 | + Subject: pkix.Name{CommonName: cn}, |
| 117 | + Issuer: pkix.Name{CommonName: cn}, |
| 118 | + NotBefore: time.Now().Add(-time.Hour), |
| 119 | + NotAfter: time.Now().Add(90 * 24 * time.Hour), |
| 120 | + DNSNames: sans, |
| 121 | + KeyUsage: x509.KeyUsageDigitalSignature, |
| 122 | + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, |
| 123 | + } |
| 124 | + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) |
| 125 | + if err != nil { |
| 126 | + t.Fatal(err) |
| 127 | + } |
| 128 | + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) |
| 129 | + keyDER, err := x509.MarshalECPrivateKey(priv) |
| 130 | + if err != nil { |
| 131 | + t.Fatal(err) |
| 132 | + } |
| 133 | + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) |
| 134 | + return |
| 135 | +} |
| 136 | + |
| 137 | +// buildMultipart serialises cert + key + optional chain as a |
| 138 | +// multipart/form-data body ready to feed httptest.NewRequest. |
| 139 | +func buildMultipart(t *testing.T, cert, key, chain string) (*bytes.Buffer, string) { |
| 140 | + t.Helper() |
| 141 | + body := &bytes.Buffer{} |
| 142 | + w := multipart.NewWriter(body) |
| 143 | + add := func(name, content string) { |
| 144 | + if content == "" { |
| 145 | + return |
| 146 | + } |
| 147 | + fw, err := w.CreateFormFile(name, name+".pem") |
| 148 | + if err != nil { |
| 149 | + t.Fatal(err) |
| 150 | + } |
| 151 | + if _, err := fw.Write([]byte(content)); err != nil { |
| 152 | + t.Fatal(err) |
| 153 | + } |
| 154 | + } |
| 155 | + add("cert_pem", cert) |
| 156 | + add("key_pem", key) |
| 157 | + add("chain_pem", chain) |
| 158 | + if err := w.Close(); err != nil { |
| 159 | + t.Fatal(err) |
| 160 | + } |
| 161 | + return body, w.FormDataContentType() |
| 162 | +} |
| 163 | + |
| 164 | +// TestUploadManualCert_EndToEnd pushes a valid cert through the full |
| 165 | +// handler: multipart parse -> validation -> encryption -> DB upsert |
| 166 | +// -> file write -> tls_mode flip. Asserts every side-effect. |
| 167 | +func TestUploadManualCert_EndToEnd(t *testing.T) { |
| 168 | + h, d, dir, hostID := newUploadTestHandlers(t) |
| 169 | + |
| 170 | + certPEM, keyPEM := genTestCert(t, "example.com", []string{"example.com"}) |
| 171 | + body, contentType := buildMultipart(t, certPEM, keyPEM, "") |
| 172 | + |
| 173 | + req := httptest.NewRequest(http.MethodPost, |
| 174 | + "/api/manual-certs/"+itoa(hostID), body) |
| 175 | + req.Header.Set("Content-Type", contentType) |
| 176 | + // Inject the {id} chi URL param so parseIDParam finds it. |
| 177 | + rctx := chi.NewRouteContext() |
| 178 | + rctx.URLParams.Add("id", itoa(hostID)) |
| 179 | + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) |
| 180 | + |
| 181 | + rr := httptest.NewRecorder() |
| 182 | + h.UploadManualCert(rr, req) |
| 183 | + |
| 184 | + if rr.Code != http.StatusOK { |
| 185 | + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) |
| 186 | + } |
| 187 | + var resp map[string]any |
| 188 | + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { |
| 189 | + t.Fatalf("decode body: %v", err) |
| 190 | + } |
| 191 | + cert := resp["cert"].(map[string]any) |
| 192 | + if cert["domain"] != "example.com" { |
| 193 | + t.Errorf("unexpected domain in response: %v", cert["domain"]) |
| 194 | + } |
| 195 | + if cert["status"] != "ok" { |
| 196 | + t.Errorf("expected status=ok for a 90-day cert, got %v", cert["status"]) |
| 197 | + } |
| 198 | + if host := resp["host"].(map[string]any); host["tls_mode"] != "manual" { |
| 199 | + t.Errorf("host.tls_mode should be manual, got %v", host["tls_mode"]) |
| 200 | + } |
| 201 | + |
| 202 | + // Files on disk. |
| 203 | + if _, err := os.Stat(filepath.Join(dir, itoa(hostID)+".crt")); err != nil { |
| 204 | + t.Errorf("cert file not written: %v", err) |
| 205 | + } |
| 206 | + if _, err := os.Stat(filepath.Join(dir, itoa(hostID)+".key")); err != nil { |
| 207 | + t.Errorf("key file not written: %v", err) |
| 208 | + } |
| 209 | + |
| 210 | + // DB row. |
| 211 | + row, err := argosdb.GetManualCertByHostID(context.Background(), d, hostID) |
| 212 | + if err != nil { |
| 213 | + t.Fatalf("get manual cert: %v", err) |
| 214 | + } |
| 215 | + if row.CertPEM == "" { |
| 216 | + t.Error("cert_pem empty in DB") |
| 217 | + } |
| 218 | + if len(row.KeyPEMEncrypted) == 0 { |
| 219 | + t.Error("key_pem_encrypted empty in DB") |
| 220 | + } |
| 221 | + // Round-trip: decrypt should return the original PEM. |
| 222 | + decrypted, err := h.Cipher.Decrypt(string(row.KeyPEMEncrypted)) |
| 223 | + if err != nil { |
| 224 | + t.Fatalf("decrypt: %v", err) |
| 225 | + } |
| 226 | + if decrypted == "" { |
| 227 | + t.Error("decrypted key is empty") |
| 228 | + } |
| 229 | + |
| 230 | + // Host row flipped. |
| 231 | + host, err := argosdb.GetHost(context.Background(), d, hostID) |
| 232 | + if err != nil { |
| 233 | + t.Fatalf("get host: %v", err) |
| 234 | + } |
| 235 | + if host.TLSMode != models.TLSModeManual { |
| 236 | + t.Errorf("host.TLSMode should be manual, got %v", host.TLSMode) |
| 237 | + } |
| 238 | +} |
| 239 | + |
| 240 | +// TestUploadManualCert_WrongDomain confirms the SAN mismatch check |
| 241 | +// rejects the upload with a 400. |
| 242 | +func TestUploadManualCert_WrongDomain(t *testing.T) { |
| 243 | + h, _, _, hostID := newUploadTestHandlers(t) |
| 244 | + certPEM, keyPEM := genTestCert(t, "other.com", []string{"other.com"}) |
| 245 | + body, contentType := buildMultipart(t, certPEM, keyPEM, "") |
| 246 | + |
| 247 | + req := httptest.NewRequest(http.MethodPost, |
| 248 | + "/api/manual-certs/"+itoa(hostID), body) |
| 249 | + req.Header.Set("Content-Type", contentType) |
| 250 | + rctx := chi.NewRouteContext() |
| 251 | + rctx.URLParams.Add("id", itoa(hostID)) |
| 252 | + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) |
| 253 | + |
| 254 | + rr := httptest.NewRecorder() |
| 255 | + h.UploadManualCert(rr, req) |
| 256 | + |
| 257 | + if rr.Code != http.StatusBadRequest { |
| 258 | + t.Fatalf("expected 400 on SAN mismatch, got %d: %s", rr.Code, rr.Body.String()) |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +func itoa(n int64) string { |
| 263 | + // strconv.FormatInt is in std already; staying local to avoid |
| 264 | + // pulling strconv for one call in a test file that otherwise |
| 265 | + // doesn't need it. |
| 266 | + return formatInt(n) |
| 267 | +} |
| 268 | + |
| 269 | +func formatInt(n int64) string { |
| 270 | + if n == 0 { |
| 271 | + return "0" |
| 272 | + } |
| 273 | + neg := n < 0 |
| 274 | + if neg { |
| 275 | + n = -n |
| 276 | + } |
| 277 | + var buf [20]byte |
| 278 | + i := len(buf) |
| 279 | + for n > 0 { |
| 280 | + i-- |
| 281 | + buf[i] = byte('0' + n%10) |
| 282 | + n /= 10 |
| 283 | + } |
| 284 | + if neg { |
| 285 | + i-- |
| 286 | + buf[i] = '-' |
| 287 | + } |
| 288 | + return string(buf[i:]) |
| 289 | +} |
0 commit comments