Skip to content

Commit 6e3180d

Browse files
committed
test: coverage for manual cert validation and upload flow
1 parent 3c05ed7 commit 6e3180d

1 file changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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

Comments
 (0)