package main import ( "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" ) func startPairing(t *testing.T, h http.Handler) (deviceCode, userCode, uriComplete string) { t.Helper() rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`) if rec.Code != 201 { t.Fatalf("start: %d %s", rec.Code, rec.Body) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } return got["deviceCode"].(string), got["userCode"].(string), got["verificationUriComplete"].(string) } func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]string) { t.Helper() rec := do(t, h, "POST", "/v1/pair/poll", "", `{"deviceCode":"`+deviceCode+`"}`) var got map[string]string _ = json.Unmarshal(rec.Body.Bytes(), &got) return rec.Code, got } func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"code": {code}, "password": {password}, "action": {action}} req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestPairingHappyPath(t *testing.T) { api, store := newTestAPI() h := api.Routes() deviceCode, userCode, uri := startPairing(t, h) if !regexpUserCode.MatchString(userCode) { t.Fatalf("user code %q", userCode) } if uri != "https://car.example.com/pair?code="+userCode { t.Fatalf("verification URI %q", uri) } if status, _ := poll(t, h, deviceCode); status != 202 { t.Fatalf("before approval: want 202, got %d", status) } // The phone opens the link: the page shows the code and asks for the password. page := do(t, h, "GET", "/pair?code="+strings.ToLower(strings.ReplaceAll(userCode, "-", "")), "", "") if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) { t.Fatalf("page: %d", page.Code) } if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { t.Fatalf("CSP %q", csp) } if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Approved") { t.Fatalf("approve: %d %s", rec.Code, rec.Body) } if len(store.devices) != 1 || store.devices[0].InstallationID != testInstallation { t.Fatalf("device not persisted: %+v", store.devices) } status, got := poll(t, h, deviceCode) if status != 200 || len(got["token"]) < minIngestTokenLength || got["deviceId"] == "" { t.Fatalf("after approval: %d %v", status, got) } // Only the hash is stored, never the token. if store.devices[0].TokenHash == got["token"] || store.devices[0].TokenHash != hashToken(got["token"]) { t.Fatal("stored token hash is wrong") } // The token is handed out once. if status, _ := poll(t, h, deviceCode); status != 410 { t.Fatalf("second collection: want 410, got %d", status) } // And it works on the ingest endpoints. rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`))) if rec.Code != 204 { t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body) } } func TestPairingWrongPasswordAndLockout(t *testing.T) { api, store := newTestAPI() h := api.Routes() deviceCode, userCode, _ := startPairing(t, h) for i := 0; i < maxFailedLogins; i++ { if rec := submit(t, h, userCode, "wrong", "approve"); rec.Code != 401 { t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) } } // Locked now — even the right password waits. if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 429 { t.Fatalf("locked: want 429, got %d", rec.Code) } if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 { t.Fatal("a locked-out attempt changed the request") } // After the window the right password works again. api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) } if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { t.Fatalf("after the window: want 200, got %d %s", rec.Code, rec.Body) } } func TestPairingDenyAndExpiry(t *testing.T) { api, _ := newTestAPI() h := api.Routes() deviceCode, userCode, _ := startPairing(t, h) if rec := submit(t, h, userCode, testApprovalPassword, "deny"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Denied") { t.Fatalf("deny: %d", rec.Code) } if status, _ := poll(t, h, deviceCode); status != 403 { t.Fatalf("denied: want 403, got %d", status) } if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { t.Fatalf("a decided code was approvable: %d", rec.Code) } deviceCode, userCode, _ = startPairing(t, h) api.Pairing.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) } if status, _ := poll(t, h, deviceCode); status != 410 { t.Fatalf("expired: want 410, got %d", status) } if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { t.Fatalf("an expired code was approvable: %d", rec.Code) } } func TestPairingNotPersistedMeansNoToken(t *testing.T) { api, store := newTestAPI() h := api.Routes() deviceCode, userCode, _ := startPairing(t, h) store.fail = errors.New("influx down") if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 500 { t.Fatalf("want 500, got %d", rec.Code) } if status, _ := poll(t, h, deviceCode); status != 202 { t.Fatalf("a token was handed out that was never saved: %d", status) } store.fail = nil if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { t.Fatalf("retry: %d", rec.Code) } if status, _ := poll(t, h, deviceCode); status != 200 { t.Fatalf("after retry: %d", status) } } func TestRevokedTokenIsRejected(t *testing.T) { api, store := newTestAPI() h := api.Routes() deviceCode, userCode, _ := startPairing(t, h) submit(t, h, userCode, testApprovalPassword, "approve") _, got := poll(t, h, deviceCode) token := got["token"] if rec := do(t, h, "GET", "/v1/devices", "wrong-password", ""); rec.Code != 401 { t.Fatalf("devices without the password: %d", rec.Code) } if rec := do(t, h, "GET", "/v1/devices", token, ""); rec.Code != 401 { t.Fatal("a car token opened the device list") } rec := do(t, h, "GET", "/v1/devices", testApprovalPassword, "") if rec.Code != 200 || !strings.Contains(rec.Body.String(), got["deviceId"]) || strings.Contains(rec.Body.String(), store.devices[0].TokenHash) { t.Fatalf("list: %d %s", rec.Code, rec.Body) } if rec := do(t, h, "POST", "/v1/devices/"+got["deviceId"]+"/revoke", testApprovalPassword, ""); rec.Code != 204 { t.Fatalf("revoke: %d", rec.Code) } if last := store.devices[len(store.devices)-1]; !last.Revoked { t.Fatal("revocation not persisted") } if rec := do(t, h, "POST", "/v1/telemetry", token, string(telemetryBody(`{"utc":1790000000}`))); rec.Code != 401 { t.Fatalf("revoked token accepted: %d", rec.Code) } // The shared ingest token is unaffected. if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 204 { t.Fatalf("shared token: %d", rec.Code) } } func TestPairedDevicesSurviveARestart(t *testing.T) { devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()}, {ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}} p := NewPairing(testApprovalPassword, devices) if !p.Authorized("restored-token-0123456789abcdef") || p.Authorized("revoked-token-0123456789abcdef") { t.Fatal("restored devices not honoured") } } func TestPairingOffWithoutAPassword(t *testing.T) { api, _ := newTestAPI() api.Pairing = NewPairing("", nil) h := api.Routes() if rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 { t.Fatalf("start: %d", rec.Code) } if rec := do(t, h, "GET", "/pair", "", ""); rec.Code != 404 { t.Fatalf("page: %d", rec.Code) } if rec := do(t, h, "GET", "/v1/devices", "", ""); rec.Code != 404 { t.Fatalf("devices: %d", rec.Code) } } func TestUserCodesNormalise(t *testing.T) { for in, want := range map[string]string{"k7f-3qx": "K7F-3QX", " K7F3QX ": "K7F-3QX", "k7f 3qx": "K7F-3QX", "K7F-3Q": "", "": ""} { if got := normalizeUserCode(in); got != want { t.Errorf("%q: got %q, want %q", in, got, want) } } } func TestApprovalPasswordMustBeLongEnough(t *testing.T) { env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", "INGEST_TOKEN": "0123456789abcdef01234567", "APPROVAL_PASSWORD": "short"} if _, err := loadConfig(func(k string) string { return env[k] }); err == nil { t.Fatal("short approval password accepted") } } var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`)