diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index e3c36f3..d9637c1 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -2,9 +2,8 @@
## Testing
-- [ ] `go test -race ./...`
-- [ ] `go vet ./...`
-- [ ] `git diff --check`
+- [ ] `make check`
+- [ ] `govulncheck ./...`
- [ ] Provider-contract changes include updated provenance/version/`last_verified` metadata
## Security and privacy
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2b40f58..2aba5d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -75,9 +75,36 @@ jobs:
run: |
unformatted="$(find . -name '*.go' -type f -exec gofmt -l {} +)"
test -z "$unformatted"
- - run: go test -race -shuffle=on ./...
+ - name: Test with coverage
+ if: runner.os == 'Linux'
+ run: go test -race -shuffle=on -coverprofile=coverage.out ./...
+ - name: Enforce coverage floor
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ coverage="$(go tool cover -func=coverage.out | awk '/^total:/ {gsub(/%/, "", $3); print $3}')"
+ echo "total statement coverage: ${coverage}%"
+ awk -v coverage="$coverage" 'BEGIN { exit !(coverage + 0 >= 70) }'
+ - name: Test
+ if: runner.os != 'Linux'
+ run: go test -race -shuffle=on ./...
- run: go vet ./...
- run: git diff --check
+ lint:
+ needs: changes
+ if: needs.changes.outputs.code == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version-file: go.mod
+ - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
+ with:
+ version: v2.12.2
security:
needs: changes
if: needs.changes.outputs.code == 'true'
@@ -99,7 +126,7 @@ jobs:
GOARCH: amd64
run: go build ./cmd/coned
required:
- needs: [changes, test, security]
+ needs: [changes, test, lint, security]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -110,10 +137,12 @@ jobs:
CHANGE_RESULT: ${{ needs.changes.result }}
CODE_CHANGED: ${{ needs.changes.outputs.code }}
TEST_RESULT: ${{ needs.test.result }}
+ LINT_RESULT: ${{ needs.lint.result }}
SECURITY_RESULT: ${{ needs.security.result }}
run: |
test "$CHANGE_RESULT" = "success"
if [[ "$CODE_CHANGED" == "true" ]]; then
test "$TEST_RESULT" = "success"
+ test "$LINT_RESULT" = "success"
test "$SECURITY_RESULT" = "success"
fi
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..e8bffd5
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,9 @@
+version: "2"
+
+run:
+ timeout: 5m
+ tests: true
+
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f9173d3..c4b9eae 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,14 +13,17 @@ For security vulnerabilities, follow [SECURITY.md](SECURITY.md) instead of openi
## Development
-Go 1.25.12 or newer is required.
+Go 1.25.12 or newer is required. Install the pinned lint runner once:
+
+```bash
+go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
+```
+
+Then run:
```bash
go mod download
-make fmt-check
-make test
-make vet
-make diff-check
+make check
make build
```
diff --git a/Makefile b/Makefile
index d599a6a..4a2b644 100644
--- a/Makefile
+++ b/Makefile
@@ -7,17 +7,20 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
DIRTY ?= $(shell test -z "$$(git status --porcelain 2>/dev/null)" && echo false || echo true)
LDFLAGS := -X $(PKG).Version=$(VERSION) -X $(PKG).Commit=$(COMMIT) -X $(PKG).Date=$(DATE) -X $(PKG).Dirty=$(DIRTY)
+GO ?= go
-.PHONY: build install test vet security fmt-check diff-check release-check check clean
+.PHONY: build install test vet lint security fmt-check diff-check release-check check clean
build:
mkdir -p $(BIN_DIR)
- go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(CMD)
+ GOTOOLCHAIN=auto $(GO) build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(CMD)
install:
- go install -ldflags "$(LDFLAGS)" $(CMD)
+ GOTOOLCHAIN=auto $(GO) install -ldflags "$(LDFLAGS)" $(CMD)
test:
- go test -race ./...
+ GOTOOLCHAIN=auto $(GO) test -race -shuffle=on ./...
vet:
- go vet ./...
+ GOTOOLCHAIN=auto $(GO) vet ./...
+lint:
+ GOTOOLCHAIN=auto golangci-lint run ./...
fmt-check:
test -z "$$(gofmt -l $$(find . -name '*.go' -type f))"
diff-check:
@@ -27,6 +30,6 @@ security:
release-check:
goreleaser check
goreleaser release --snapshot --clean
-check: fmt-check test vet diff-check
+check: fmt-check test vet lint diff-check
clean:
rm -rf $(BIN_DIR)
diff --git a/README.md b/README.md
index 2cdfba1..729a167 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# coned-cli
[](https://github.com/zzwong/coned-cli/actions/workflows/ci.yml)
+[](https://github.com/zzwong/coned-cli/actions/workflows/ci.yml)
[](LICENSE)
A headless CLI for accessing Con Edison account, billing, and energy-usage data.
@@ -212,10 +213,7 @@ CLI flags override the configured profile and timeout. The production client acc
## Development
```bash
-make fmt-check
-make test
-make vet
-make diff-check
+make check
make build
./bin/coned version
```
diff --git a/internal/cli/auth.go b/internal/cli/auth.go
index 2e03d0b..f63cf38 100644
--- a/internal/cli/auth.go
+++ b/internal/cli/auth.go
@@ -268,11 +268,11 @@ func authStatus(cmd *cobra.Command, profile string, jsonOutput bool, deps Depend
if err != nil {
return err
}
- fmt.Fprintln(cmd.OutOrStdout(), string(data))
- return nil
+ _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
+ return err
}
- fmt.Fprintln(cmd.OutOrStdout(), statusName(state))
- return nil
+ _, err = fmt.Fprintln(cmd.OutOrStdout(), statusName(state))
+ return err
}
func authLogout(cmd *cobra.Command, profile string, timeout time.Duration, deps Dependencies, forget bool) error {
@@ -299,6 +299,6 @@ func authLogout(cmd *cobra.Command, profile string, timeout time.Duration, deps
if len(errorsFound) > 0 {
return errors.Join(errorsFound...)
}
- fmt.Fprintln(cmd.OutOrStdout(), "not authenticated")
- return nil
+ _, err := fmt.Fprintln(cmd.OutOrStdout(), "not authenticated")
+ return err
}
diff --git a/internal/cli/bills.go b/internal/cli/bills.go
index 5925773..1199ce3 100644
--- a/internal/cli/bills.go
+++ b/internal/cli/bills.go
@@ -116,8 +116,8 @@ func downloadBill(cmd *cobra.Command, options *Options, deps Dependencies, id, o
keep := false
defer func() {
if !keep {
- file.Close()
- os.Remove(temp)
+ _ = file.Close()
+ _ = os.Remove(temp)
}
}()
ctx, cancel := context.WithTimeout(cmd.Context(), options.Timeout)
@@ -136,8 +136,8 @@ func downloadBill(cmd *cobra.Command, options *Options, deps Dependencies, id, o
return coned.ErrProtocolChanged
}
keep = true
- fmt.Fprintln(cmd.OutOrStdout(), output)
- return nil
+ _, err = fmt.Fprintln(cmd.OutOrStdout(), output)
+ return err
}
func billDateFromID(id string) (string, bool) {
@@ -172,8 +172,8 @@ func newOutputFile(output string, force bool) (*os.File, string, error) {
return nil, "", coned.ErrProtocolChanged
}
if err := file.Chmod(0o600); err != nil {
- file.Close()
- os.Remove(file.Name())
+ _ = file.Close()
+ _ = os.Remove(file.Name())
return nil, "", coned.ErrProtocolChanged
}
return file, file.Name(), nil
diff --git a/internal/cli/bills_test.go b/internal/cli/bills_test.go
index 7ad0627..2e63e1d 100644
--- a/internal/cli/bills_test.go
+++ b/internal/cli/bills_test.go
@@ -221,11 +221,11 @@ func TestConcreteDownloadFailuresCleanUpAtCLILayer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/en/accounts-billing/billing-history":
- io.WriteString(w, ``)
+ _, _ = io.WriteString(w, ``)
case "/sitecore/api/ssc/ConEdWeb-Foundation-MyAccount-Areas-BillingHistory-BillingHistoryAPI/User/0/GetResidentialBillHistory":
- io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-synthetic"}]`)
+ _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-synthetic"}]`)
case "/sitecore/api/ssc/ConEdWeb-Foundation-MyAccount-Areas-BillingHistory-BillingHistoryAPI/User/0/BillInsertImage":
- io.WriteString(w, tc.documentResponse)
+ _, _ = io.WriteString(w, tc.documentResponse)
default:
http.NotFound(w, r)
}
diff --git a/internal/cli/diagnostics.go b/internal/cli/diagnostics.go
index 22bfe41..6d75cdb 100644
--- a/internal/cli/diagnostics.go
+++ b/internal/cli/diagnostics.go
@@ -70,11 +70,11 @@ func newDiagnosticsCommand(options *Options, deps Dependencies) *cobra.Command {
err = closeErr
}
if err != nil {
- os.Remove(temp)
+ _ = os.Remove(temp)
return coned.ErrProtocolChanged
}
if err = publishOutput(temp, output, false); err != nil {
- os.Remove(temp)
+ _ = os.Remove(temp)
return coned.ErrProtocolChanged
}
return nil
diff --git a/internal/cli/entities.go b/internal/cli/entities.go
index 59ef25b..8590f36 100644
--- a/internal/cli/entities.go
+++ b/internal/cli/entities.go
@@ -208,8 +208,8 @@ func selectEntity(cmd *cobra.Command, options *Options, deps Dependencies, value
if cfg.Save(deps.ConfigPath) != nil {
return coned.ErrProtocolChanged
}
- fmt.Fprintln(cmd.OutOrStdout(), "selected "+strings.SplitN(value, "-", 2)[0])
- return nil
+ _, err = fmt.Fprintln(cmd.OutOrStdout(), "selected "+strings.SplitN(value, "-", 2)[0])
+ return err
}
func resolveEntitySelection(ctx context.Context, options *Options, deps Dependencies, session auth.Session) (coned.EntitySelection, error) {
account, meter := options.Account, options.Meter
diff --git a/internal/cli/green_button.go b/internal/cli/green_button.go
index 40dda5d..3c4eef6 100644
--- a/internal/cli/green_button.go
+++ b/internal/cli/green_button.go
@@ -106,7 +106,7 @@ func greenExport(cmd *cobra.Command, o *Options, d Dependencies, format, from, t
if e != nil {
return safeGreenError(e)
}
- defer os.Remove(spool)
+ defer func() { _ = os.Remove(spool) }()
if download {
if output == "" {
output = "green-button-usage.zip"
@@ -118,8 +118,8 @@ func greenExport(cmd *cobra.Command, o *Options, d Dependencies, format, from, t
keep := false
defer func() {
if !keep {
- f.Close()
- os.Remove(tmp)
+ _ = f.Close()
+ _ = os.Remove(tmp)
}
}()
in, e := os.Open(spool)
@@ -127,16 +127,16 @@ func greenExport(cmd *cobra.Command, o *Options, d Dependencies, format, from, t
return coned.ErrProtocolChanged
}
_, e = io.Copy(f, in)
- in.Close()
- if e != nil || f.Sync() != nil || f.Close() != nil {
+ closeErr := in.Close()
+ if e != nil || closeErr != nil || f.Sync() != nil || f.Close() != nil {
return coned.ErrProtocolChanged
}
if publishOutput(tmp, output, force) != nil {
return coned.ErrProtocolChanged
}
keep = true
- fmt.Fprintln(cmd.OutOrStdout(), output)
- return nil
+ _, e = fmt.Fprintln(cmd.OutOrStdout(), output)
+ return e
}
if format == "json" {
e = coned.GreenButtonCSVJSON(spool, cmd.OutOrStdout())
diff --git a/internal/cli/green_button_test.go b/internal/cli/green_button_test.go
new file mode 100644
index 0000000..9946107
--- /dev/null
+++ b/internal/cli/green_button_test.go
@@ -0,0 +1,112 @@
+package cli
+
+import (
+ "archive/zip"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/zzwong/coned-cli/internal/auth"
+ "github.com/zzwong/coned-cli/internal/coned"
+)
+
+type fakeGreenButton struct {
+ *fakeOpower
+ t *testing.T
+ dir string
+ options coned.GreenButtonOptions
+ err error
+}
+
+func (f *fakeGreenButton) GreenButtonInspect(context.Context, auth.Session) (coned.GreenButtonMetadata, error) {
+ return coned.GreenButtonMetadata{CustomerClass: "RESIDENTIAL", TimeZone: "America/New_York", BillIntervals: []string{"2026-01"}, AMIIntervals: []string{"2026-01", "2026-02"}}, f.err
+}
+
+func (f *fakeGreenButton) GreenButtonExport(_ context.Context, _ auth.Session, options coned.GreenButtonOptions) (string, error) {
+ f.options = options
+ if f.err != nil {
+ return "", f.err
+ }
+ path := filepath.Join(f.dir, "spool-"+options.Format+".zip")
+ file, err := os.Create(path)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ archive := zip.NewWriter(file)
+ name, body := "usage.csv", "TYPE,DATE,USAGE\nElectric usage,2026-01-01,3\n"
+ if options.Format == "xml" {
+ name, body = "usage.xml", "synthetic"
+ }
+ member, err := archive.Create(name)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ if _, err := member.Write([]byte(body)); err != nil {
+ f.t.Fatal(err)
+ }
+ if err := archive.Close(); err != nil {
+ f.t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ f.t.Fatal(err)
+ }
+ return path, nil
+}
+
+func greenButtonDeps(t *testing.T) (*fakeGreenButton, Dependencies) {
+ fake := &fakeGreenButton{fakeOpower: &fakeOpower{}, t: t, dir: t.TempDir()}
+ deps := opowerDeps(t, fake.fakeOpower)
+ deps.Opower = fake
+ return fake, deps
+}
+
+func TestGreenButtonInspectExportAndDownload(t *testing.T) {
+ fake, deps := greenButtonDeps(t)
+ output, err := runWithDependencies(t, deps, "", "green-button inspect")
+ if err != nil || !strings.Contains(output, "bill intervals: 1") {
+ t.Fatalf("output=%q err=%v", output, err)
+ }
+ output, err = runWithDependencies(t, deps, "", "--json green-button inspect")
+ if err != nil || !strings.Contains(output, `"customer_class":"RESIDENTIAL"`) {
+ t.Fatalf("output=%q err=%v", output, err)
+ }
+ output, err = runWithDependencies(t, deps, "", "green-button export --format json --from 2026-01-01 --to 2026-01-31")
+ if err != nil || fake.options.Format != "csv" || !strings.Contains(output, `"USAGE":"3"`) {
+ t.Fatalf("output=%q options=%#v err=%v", output, fake.options, err)
+ }
+ output, err = runWithDependencies(t, deps, "", "green-button export --format xml")
+ if err != nil || output != "synthetic" {
+ t.Fatalf("output=%q err=%v", output, err)
+ }
+ destination := filepath.Join(t.TempDir(), "usage.zip")
+ output, err = runWithDependencies(t, deps, "", "green-button download --format csv --output "+destination)
+ if err != nil || strings.TrimSpace(output) != destination {
+ t.Fatalf("output=%q err=%v", output, err)
+ }
+ if info, err := os.Stat(destination); err != nil || info.Mode().Perm() != 0o600 {
+ t.Fatalf("stat=%v err=%v", info, err)
+ }
+}
+
+func TestGreenButtonValidationAndSafeErrors(t *testing.T) {
+ _, deps := greenButtonDeps(t)
+ for _, command := range []string{
+ "green-button export --format zip",
+ "green-button download --format json",
+ "green-button export --from 2026-01-01",
+ "green-button export --from bad --to 2026-01-01",
+ "green-button export --from 2026-02-01 --to 2026-01-01",
+ } {
+ if _, err := runWithDependencies(t, deps, "", command); !errors.Is(err, coned.ErrProtocolChanged) {
+ t.Fatalf("%q: %v", command, err)
+ }
+ }
+ fake, deps := greenButtonDeps(t)
+ fake.err = errors.New("private detail: " + coned.ErrGreenButtonUnavailable.Error())
+ if _, err := runWithDependencies(t, deps, "", "green-button inspect"); !errors.Is(err, coned.ErrGreenButtonUnavailable) {
+ t.Fatalf("error=%v", err)
+ }
+}
diff --git a/internal/cli/opower.go b/internal/cli/opower.go
index 6271b18..0a38a68 100644
--- a/internal/cli/opower.go
+++ b/internal/cli/opower.go
@@ -255,8 +255,8 @@ func runOpowerExport(cmd *cobra.Command, options *Options, deps Dependencies, fo
keep := false
defer func() {
if !keep {
- file.Close()
- os.Remove(temp)
+ _ = file.Close()
+ _ = os.Remove(temp)
}
}()
if _, err = file.Write(data); err != nil || file.Sync() != nil || file.Close() != nil {
@@ -419,8 +419,8 @@ func newOutputFileWithPrefix(output string, force bool, prefix string) (*os.File
return nil, "", err
}
if err := file.Chmod(0o600); err != nil {
- file.Close()
- os.Remove(file.Name())
+ _ = file.Close()
+ _ = os.Remove(file.Name())
return nil, "", err
}
return file, file.Name(), nil
diff --git a/internal/coned/auth.go b/internal/coned/auth.go
index 9063b10..8b18feb 100644
--- a/internal/coned/auth.go
+++ b/internal/coned/auth.go
@@ -47,14 +47,14 @@ func (c *Client) Authenticate(ctx context.Context, credentials auth.Credentials)
resetURL, _ := url.Parse("https://coned.okta.com/api/v1/sessions/me")
if reset, resetErr := c.request(ctx, http.MethodDelete, resetURL, nil, false); resetErr == nil {
c.debugf("auth Okta reset response status=%d", reset.StatusCode)
- reset.Body.Close()
+ _ = reset.Body.Close()
}
c.debugf("auth login request started")
response, err := c.request(ctx, http.MethodPost, c.endpoint(loginPath), bytes.NewReader(data), true)
if err != nil {
return auth.Session{}, transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
c.debugf("auth login response status=%d", response.StatusCode)
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
@@ -163,7 +163,7 @@ func (c *Client) ResendMFA(ctx context.Context) error {
if err != nil {
return transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
c.debugf("auth MFA resend response status=%d", response.StatusCode)
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
if response.StatusCode >= 200 && response.StatusCode < 300 {
@@ -198,7 +198,7 @@ func (c *Client) VerifyMFA(ctx context.Context, code string) (auth.Session, erro
if err != nil {
return auth.Session{}, transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
c.debugf("auth MFA verification response status=%d", response.StatusCode)
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
@@ -369,7 +369,7 @@ func (c *Client) follow(ctx context.Context, next *url.URL) error {
if err != nil {
return transportError(ctx)
}
- response.Body.Close()
+ _ = response.Body.Close()
c.debugf("auth redirect response status=%d", response.StatusCode)
for _, cookie := range response.Cookies() {
c.debugf("auth redirect set-cookie name=%s domain=%s path=%s value_len=%d max_age=%d expires_set=%t expires_future=%t", cookie.Name, strings.TrimPrefix(cookie.Domain, "."), cookie.Path, len(cookie.Value), cookie.MaxAge, !cookie.Expires.IsZero(), cookie.Expires.After(time.Now()))
@@ -404,7 +404,7 @@ func (c *Client) confirm(ctx context.Context) error {
if err != nil {
return transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
c.debugf("auth confirmation response status=%d", response.StatusCode)
if response.StatusCode >= 300 && response.StatusCode < 400 {
location, err := response.Location()
@@ -456,7 +456,7 @@ func (c *Client) Logout(ctx context.Context, session auth.Session) error {
}
return auth.ErrLogoutFailed
}
- response.Body.Close()
+ _ = response.Body.Close()
if (response.StatusCode >= 200 && response.StatusCode < 300) || response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusNotFound {
return nil
}
diff --git a/internal/coned/auth_test.go b/internal/coned/auth_test.go
index a3e959c..da735bb 100644
--- a/internal/coned/auth_test.go
+++ b/internal/coned/auth_test.go
@@ -48,7 +48,7 @@ func TestAuthenticateSuccessAndExactPayload(t *testing.T) {
t.Errorf("incorrect payload: %s", body)
}
fixture := readFixture(t, "login_success.json")
- w.Write([]byte(strings.ReplaceAll(fixture, "https://www.coned.com", server.URL)))
+ _, _ = w.Write([]byte(strings.ReplaceAll(fixture, "https://www.coned.com", server.URL)))
case "/authorize":
if r.URL.Query().Get("state") != "synthetic" || r.URL.Query().Get("nonce") != "synthetic" || r.URL.Query().Get("sessionToken") != "synthetic-session-token" {
t.Error("authorize state, nonce, or session token was not preserved")
@@ -94,7 +94,7 @@ func TestVerifyMFAExactPayloadAndSession(t *testing.T) {
t.Errorf("incorrect MFA payload: %s", body)
}
w.Header().Set("Content-Type", "application/json")
- w.Write([]byte(`{"code":true,"authRedirectUrl":"` + server.URL + `/complete"}`))
+ _, _ = w.Write([]byte(`{"code":true,"authRedirectUrl":"` + server.URL + `/complete"}`))
case "/complete":
http.SetCookie(w, &http.Cookie{Name: "CE_AUTH", Value: "synthetic", Path: "/", HttpOnly: true})
w.WriteHeader(http.StatusOK)
@@ -123,7 +123,7 @@ func TestVerifyMFARejectsInvalidCodeAndUnsafeRedirect(t *testing.T) {
{"unsafe", `{"code":true,"authRedirectUrl":"https://evil.example/"}`, ErrProtocolChanged},
} {
t.Run(tc.name, func(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(tc.response)) }))
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(tc.response)) }))
defer server.Close()
_, err := testClient(t, server).VerifyMFA(context.Background(), "123456")
if !errors.Is(err, tc.want) {
@@ -174,9 +174,9 @@ func TestAuthenticationResponseErrors(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
if tc.name == "invalid" {
- w.Write([]byte(readFixture(t, "login_failure.json")))
+ _, _ = w.Write([]byte(readFixture(t, "login_failure.json")))
} else {
- w.Write([]byte(tc.body))
+ _, _ = w.Write([]byte(tc.body))
}
}))
defer server.Close()
@@ -208,7 +208,7 @@ func TestUnsafeRedirectAndRedirectToLogin(t *testing.T) {
if tc.authorize {
body = `{"url":"` + server.URL + `/authorize"}`
}
- w.Write([]byte(body))
+ _, _ = w.Write([]byte(body))
return
}
if r.URL.Path == "/authorize" {
@@ -237,7 +237,7 @@ func TestTimeoutAndCookieFilteringAndRestore(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == loginPath {
time.Sleep(100 * time.Millisecond)
- w.Write([]byte(`{}`))
+ _, _ = w.Write([]byte(`{}`))
return
}
w.WriteHeader(http.StatusOK)
@@ -361,7 +361,7 @@ func TestLogoutClearsOnFailureAndHonorsCancellation(t *testing.T) {
func TestProtocolErrorDiagnosticsAreSafe(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("x-ms-middleware-request-id", "safe-request_123")
- w.Write([]byte(`{`))
+ _, _ = w.Write([]byte(`{`))
}))
defer server.Close()
_, err := testClient(t, server).Authenticate(context.Background(), auth.Credentials{Email: "a@b", Password: "p"})
@@ -423,7 +423,7 @@ func TestConfirmationProtocolErrorDiagnostics(t *testing.T) {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case loginPath:
- w.Write([]byte(strings.ReplaceAll(readFixture(t, "login_success.json"), "https://www.coned.com", server.URL)))
+ _, _ = w.Write([]byte(strings.ReplaceAll(readFixture(t, "login_success.json"), "https://www.coned.com", server.URL)))
case "/authorize":
w.WriteHeader(http.StatusOK)
case accountPath:
diff --git a/internal/coned/bills.go b/internal/coned/bills.go
index 3ccdcab..86f4a51 100644
--- a/internal/coned/bills.go
+++ b/internal/coned/bills.go
@@ -108,9 +108,9 @@ func (c *Client) DownloadBill(ctx context.Context, session auth.Session, id stri
return billDownloadError(id, transportError(ctx))
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
- response.Body.Close()
- if readErr != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
- if readErr != nil {
+ closeErr := response.Body.Close()
+ if readErr != nil || closeErr != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
+ if readErr != nil || closeErr != nil {
return billDownloadError(id, ErrProtocolChanged)
}
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || isLoginRedirect(response) {
@@ -126,7 +126,7 @@ func (c *Client) DownloadBill(ctx context.Context, session auth.Session, id stri
if err != nil {
return billDownloadError(id, transportError(ctx))
}
- defer pdf.Body.Close()
+ defer func() { _ = pdf.Body.Close() }()
if pdf.StatusCode < 200 || pdf.StatusCode >= 300 {
if pdf.StatusCode == http.StatusUnauthorized || pdf.StatusCode == http.StatusForbidden || isLoginRedirect(pdf) {
return billDownloadError(id, ErrSessionExpired)
@@ -151,7 +151,7 @@ func (c *Client) billRecords(ctx context.Context, session auth.Session) (billing
if err != nil {
return nil, nil, transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return nil, nil, ErrProtocolChanged
@@ -178,7 +178,7 @@ func (c *Client) historyMetadata(ctx context.Context, session auth.Session) (bil
if err != nil {
return nil, transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return nil, ErrProtocolChanged
diff --git a/internal/coned/bills_test.go b/internal/coned/bills_test.go
index fd0d8e0..64d9a39 100644
--- a/internal/coned/bills_test.go
+++ b/internal/coned/bills_test.go
@@ -34,13 +34,13 @@ func TestListBillsUsesAuthenticatedHistoryAndReturnsPublicNewestFirst(t *testing
}
switch r.URL.Path {
case billHistoryPath:
- io.WriteString(w, readFixture(t, "bill_history.html"))
+ _, _ = io.WriteString(w, readFixture(t, "bill_history.html"))
case residentialBillHistoryPath:
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "synthetic-maid") {
t.Error("metadata was not forwarded")
}
- io.WriteString(w, `{"data":[{"BillDate":"01/15/2026","Cycle":"January","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","Cycle":"February","DocumentId":"opaque-two","DocumentType":"bill"}]}`)
+ _, _ = io.WriteString(w, `{"data":[{"BillDate":"01/15/2026","Cycle":"January","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","Cycle":"February","DocumentId":"opaque-two","DocumentType":"bill"}]}`)
default:
http.NotFound(w, r)
}
@@ -76,15 +76,15 @@ func TestDownloadBillValidatesURLSizeAndPDF(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case billHistoryPath:
- io.WriteString(w, readFixture(t, "bill_history.html"))
+ _, _ = io.WriteString(w, readFixture(t, "bill_history.html"))
case residentialBillHistoryPath:
- io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque","DocumentType":"bill"}]`)
+ _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque","DocumentType":"bill"}]`)
case billInsertImagePath:
if tc.status != 0 {
w.WriteHeader(tc.status)
return
}
- io.WriteString(w, `{"url":"`+tc.documentURL+`"}`)
+ _, _ = io.WriteString(w, `{"url":"`+tc.documentURL+`"}`)
default:
http.NotFound(w, r)
}
@@ -147,9 +147,9 @@ func TestBillSessionExpiryAndMissingBill(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case billHistoryPath:
- io.WriteString(w, readFixture(t, "bill_history.html"))
+ _, _ = io.WriteString(w, readFixture(t, "bill_history.html"))
case residentialBillHistoryPath:
- io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`)
+ _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`)
}
}))
defer server.Close()
@@ -244,9 +244,9 @@ func TestBillingOperationsSerializeSessionRestore(t *testing.T) {
} else {
secondHistory <- cookie.Value
}
- io.WriteString(w, ``)
+ _, _ = io.WriteString(w, ``)
case residentialBillHistoryPath:
- io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`)
+ _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`)
default:
http.NotFound(w, r)
}
@@ -304,15 +304,15 @@ func TestSameDateIDsAreSafeAndAmbiguousDownloadsAreRejected(t *testing.T) {
switch r.URL.Path {
case billHistoryPath:
history++
- io.WriteString(w, readFixture(t, "bill_history.html"))
+ _, _ = io.WriteString(w, readFixture(t, "bill_history.html"))
case residentialBillHistoryPath:
- io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","DocumentId":"opaque-two","DocumentType":"bill"}]`)
+ _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","DocumentId":"opaque-two","DocumentType":"bill"}]`)
case billInsertImagePath:
insert++
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
- io.WriteString(w, `{"url":"https://synthetic.blob.core.windows.net/bill.pdf?sig=synthetic"}`)
+ _, _ = io.WriteString(w, `{"url":"https://synthetic.blob.core.windows.net/bill.pdf?sig=synthetic"}`)
default:
http.NotFound(w, r)
}
diff --git a/internal/coned/browser_import.go b/internal/coned/browser_import.go
index ad7376a..6902355 100644
--- a/internal/coned/browser_import.go
+++ b/internal/coned/browser_import.go
@@ -57,7 +57,7 @@ func ImportBrowserSession(ctx context.Context, endpoint string) (auth.Session, e
if err != nil {
return auth.Session{}, errors.New("connect to browser endpoint")
}
- defer conn.Close()
+ defer func() { _ = conn.Close() }()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetReadDeadline(deadline)
_ = conn.SetWriteDeadline(deadline)
@@ -193,7 +193,7 @@ func resolveBrowserWebSocket(ctx context.Context, endpoint string) (string, erro
if err != nil {
return "", errors.New("query browser endpoint")
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("query browser endpoint: status %d", response.StatusCode)
}
diff --git a/internal/coned/diagnostics.go b/internal/coned/diagnostics.go
index 0a850c2..6e15b84 100644
--- a/internal/coned/diagnostics.go
+++ b/internal/coned/diagnostics.go
@@ -31,7 +31,7 @@ func (c *Client) SchemaDiagnostics(ctx context.Context, session auth.Session) ([
if err != nil {
return nil, transportError(ctx)
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, protocolError(resp)
}
diff --git a/internal/coned/discovery_test.go b/internal/coned/discovery_test.go
new file mode 100644
index 0000000..c5af7c7
--- /dev/null
+++ b/internal/coned/discovery_test.go
@@ -0,0 +1,61 @@
+package coned
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+)
+
+func TestDiscoverBuildsDeduplicatedHierarchy(t *testing.T) {
+ client, err := NewClient(Options{Transport: opowerRT(func(request *http.Request) (*http.Response, error) {
+ var query struct {
+ OperationName string `json:"operationName"`
+ }
+ if err := json.NewDecoder(request.Body).Decode(&query); err != nil {
+ t.Fatal(err)
+ }
+ switch query.OperationName {
+ case "WBAS_BillingAccounts":
+ return opowerResponse(`{"data":{"billingAccountsConnection":{"edges":[{"node":{"urn":"account-1"}}]}}}`), nil
+ case "WRTAMI_GetMetadata":
+ return opowerResponse(`{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"uuid":"sa-1","serviceType":"ELECTRIC","servicePointsConnection":{"edges":[{"node":{"uuid":"meter-1","premise":{"uuid":"premise-1"},"registers":[{"serviceQuantityIdentifier":"register-1","readResolution":"HOUR","unitOfMeasure":"kWh"}]}},{"node":{"uuid":"meter-2","premise":{"uuid":"premise-1"},"registers":[{"readResolution":"DAY","unitOfMeasure":"kWh"}]}}]}}}]}}}}`), nil
+ default:
+ t.Fatalf("unexpected operation %q", query.OperationName)
+ return nil, errors.New("unexpected operation")
+ }
+ })})
+ if err != nil {
+ t.Fatal(err)
+ }
+ entities, err := client.Discover(context.Background(), opowerSession(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entities) != 6 {
+ t.Fatalf("entities=%#v", entities)
+ }
+ counts := map[string]int{}
+ for _, entity := range entities {
+ counts[entity.Type]++
+ if entity.ContractVersion != 1 || entity.LastVerified == "" {
+ t.Fatalf("missing contract metadata: %#v", entity)
+ }
+ }
+ if counts["account"] != 1 || counts["premise"] != 1 || counts["meter"] != 2 || counts["register"] != 2 {
+ t.Fatalf("counts=%v", counts)
+ }
+}
+
+func TestDiscoverRejectsEmptyAccounts(t *testing.T) {
+ client, err := NewClient(Options{Transport: opowerRT(func(*http.Request) (*http.Response, error) {
+ return opowerResponse(`{"data":{"billingAccountsConnection":{"edges":[]}}}`), nil
+ })})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Discover(context.Background(), opowerSession(true)); !errors.Is(err, ErrProtocolChanged) {
+ t.Fatalf("error=%v", err)
+ }
+}
diff --git a/internal/coned/green_button.go b/internal/coned/green_button.go
index c6d5c2c..c35bb96 100644
--- a/internal/coned/green_button.go
+++ b/internal/coned/green_button.go
@@ -227,7 +227,7 @@ func (c *Client) GreenButtonExport(ctx context.Context, s auth.Session, o GreenB
if err != nil {
return "", transportError(ctx)
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", protocolError(resp)
}
@@ -236,22 +236,22 @@ func (c *Client) GreenButtonExport(ctx context.Context, s auth.Session, o GreenB
return "", ErrProtocolChanged
}
if err = file.Chmod(0600); err != nil {
- file.Close()
- os.Remove(file.Name())
+ _ = file.Close()
+ _ = os.Remove(file.Name())
return "", ErrProtocolChanged
}
n, err := io.Copy(file, io.LimitReader(resp.Body, maxExportZIP+1))
if err != nil || n > maxExportZIP {
- file.Close()
- os.Remove(file.Name())
+ _ = file.Close()
+ _ = os.Remove(file.Name())
return "", ErrProtocolChanged
}
if err = file.Close(); err != nil {
- os.Remove(file.Name())
+ _ = os.Remove(file.Name())
return "", ErrProtocolChanged
}
if err = validateExportZIP(file.Name()); err != nil {
- os.Remove(file.Name())
+ _ = os.Remove(file.Name())
return "", err
}
return file.Name(), nil
@@ -268,7 +268,7 @@ func validateExportZIP(name string) error {
if err != nil {
return ErrProtocolChanged
}
- defer z.Close()
+ defer func() { _ = z.Close() }()
if len(z.File) == 0 || len(z.File) > maxExportEntries {
return ErrProtocolChanged
}
@@ -288,7 +288,7 @@ func ExtractGreenButton(name, format string, out io.Writer) error {
if err != nil {
return ErrProtocolChanged
}
- defer z.Close()
+ defer func() { _ = z.Close() }()
ext := "." + strings.ToLower(format)
for _, f := range z.File {
if strings.EqualFold(filepath.Ext(f.Name), ext) {
@@ -297,8 +297,8 @@ func ExtractGreenButton(name, format string, out io.Writer) error {
return ErrProtocolChanged
}
_, e = io.Copy(out, io.LimitReader(r, maxExportEntry+1))
- r.Close()
- if e != nil {
+ closeErr := r.Close()
+ if e != nil || closeErr != nil {
return ErrProtocolChanged
}
return nil
@@ -311,14 +311,14 @@ func GreenButtonCSVJSON(name string, out io.Writer) error {
if err != nil {
return ErrProtocolChanged
}
- defer z.Close()
+ defer func() { _ = z.Close() }()
for _, f := range z.File {
if strings.EqualFold(filepath.Ext(f.Name), ".csv") {
r, e := f.Open()
if e != nil {
return ErrProtocolChanged
}
- defer r.Close()
+ defer func() { _ = r.Close() }()
cr := csv.NewReader(io.LimitReader(r, maxExportEntry+1))
cr.FieldsPerRecord = -1
var heads []string
diff --git a/internal/coned/opower.go b/internal/coned/opower.go
index 75b088f..3b7799c 100644
--- a/internal/coned/opower.go
+++ b/internal/coned/opower.go
@@ -70,7 +70,7 @@ func (c *Client) mintOpowerToken(ctx context.Context) (string, error) {
if err != nil {
return "", transportError(ctx)
}
- defer response.Body.Close()
+ defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return "", ErrProtocolChanged
@@ -112,7 +112,7 @@ func (c *Client) opower(ctx context.Context, session auth.Session, operation, qu
if err != nil {
return transportError(ctx)
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return ErrProtocolChanged
diff --git a/internal/coned/opower_history.go b/internal/coned/opower_history.go
index 040e777..78cbdb2 100644
--- a/internal/coned/opower_history.go
+++ b/internal/coned/opower_history.go
@@ -305,7 +305,7 @@ func (c *Client) opowerGET(ctx context.Context, session auth.Session, token, end
if err != nil {
return transportError(ctx)
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return ErrProtocolChanged
diff --git a/internal/coned/opower_test.go b/internal/coned/opower_test.go
index bd2753e..b838f0e 100644
--- a/internal/coned/opower_test.go
+++ b/internal/coned/opower_test.go
@@ -57,6 +57,56 @@ func TestOpowerAccountsMaskIDsAndRejectPartialErrors(t *testing.T) {
t.Fatalf("missing token=%v", err)
}
}
+func TestOpowerUsageResourcesEndToEnd(t *testing.T) {
+ responses := map[string]string{
+ "WBAS_BillingAccounts": `{"data":{"billingAccountsConnection":{"edges":[{"node":{"urn":"urn:synthetic:account","name":"Home","utilityCode":"SYN","customerClass":"RES","serviceAgreementsConnection":{"totalCount":1}}}]}}}`,
+ "WRTAMI_GetMetadata": `{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"uuid":"sa-1","serviceType":"ELECTRIC","servicePointsConnection":{"edges":[{"node":{"uuid":"sp-1","premise":{"uuid":"premise-1"},"registers":[{"readResolution":"HOUR","unitOfMeasure":"kWh","availableReadsTimeInterval":"2026-01-01/2026-02-01"}]}}]}}}]}}}}`,
+ "WDB_GetCostUsageReadsForBills": `{"data":{"billingAccountByAuthContext":{"bills":[{"timeInterval":"2026-01-01/2026-02-01","segments":[{"usageInterval":"2026-01-01/2026-02-01","estimated":true,"serviceQuantities":[{"unit":"kWh","serviceQuantity":{"value":10}}],"usageCharges":{"value":3.5}}]}]}}}`,
+ "WDB_GetWeather": `{"data":{"billingAccountByAuthContext":{"premisesConnection":{"edges":[{"node":{"weather":[{"timeInterval":"2026-01-01/2026-01-02","minTemperature":{"value":20},"meanTemperature":{"value":30},"maxTemperature":{"value":40}}]}}]}}}}`,
+ "WDB_GetNeighborComparisons": `{"data":{"billingAccountByAuthContext":{"premisesConnection":{"edges":[{"elec":[{"meterType":"ELEC","timeInterval":"2026-01-01/2026-02-01","youUsage":{"unit":"kWh","value":10},"efficientNeighborsThresholdUsage":{"unit":"kWh","value":8},"averageOfAllNeighborsUsage":{"unit":"kWh","value":12},"numberOfNeighbors":100}]}]}}}}`,
+ "WRTAMI_GetRegisters": `{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"servicePointsConnection":{"edges":[{"node":{"intervalReads":[{"registerId":"register-1"}]}}]}}}]}}}}`,
+ "WRTAMI_GetRegisterUsage": `{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"servicePointsConnection":{"edges":[{"node":{"intervalReads":[{"unit":"kWh","reads":[{"timeInterval":"2026-01-01T00:00:00Z/2026-01-01T01:00:00Z","measuredAmount":{"value":1.25}}]}]}}]}}}]}}}}`,
+ }
+ client, err := NewClient(Options{Transport: opowerRT(func(request *http.Request) (*http.Response, error) {
+ var query struct {
+ OperationName string `json:"operationName"`
+ }
+ if err := json.NewDecoder(request.Body).Decode(&query); err != nil {
+ t.Fatal(err)
+ }
+ body, ok := responses[query.OperationName]
+ if !ok {
+ t.Fatalf("unexpected operation %q", query.OperationName)
+ }
+ return opowerResponse(body), nil
+ })})
+ if err != nil {
+ t.Fatal(err)
+ }
+ session := opowerSession(true)
+ selection := EntitySelection{Account: "urn:synthetic:account", Meter: "sp-1"}
+ for _, resource := range []string{"accounts", "usage-bills", "weather", "neighbors", "meters", "realtime", "summary", "export"} {
+ value, err := client.FetchSelected(context.Background(), session, resource, selection)
+ if err != nil {
+ t.Fatalf("%s: %v", resource, err)
+ }
+ if value == nil {
+ t.Fatalf("%s returned nil", resource)
+ }
+ }
+ reads, err := client.UsageRealtime(context.Background(), session, UsageOptions{Account: selection.Account})
+ if err != nil || len(reads) != 1 || reads[0].Value != 1.25 {
+ t.Fatalf("reads=%#v err=%v", reads, err)
+ }
+ summary, err := client.UsageSummary(context.Background(), session, UsageOptions{Account: selection.Account})
+ if err != nil || summary.Bills != 1 || summary.Usage.Value != 10 || summary.Charges != 3.5 {
+ t.Fatalf("summary=%#v err=%v", summary, err)
+ }
+ if _, err := client.FetchSelected(context.Background(), session, "unknown", selection); !errors.Is(err, ErrProtocolChanged) {
+ t.Fatalf("unknown resource: %v", err)
+ }
+}
+
func TestRealtimeEmptyIsUnavailable(t *testing.T) {
client, err := NewClient(Options{Transport: opowerRT(func(*http.Request) (*http.Response, error) {
return opowerResponse(`{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[]}}}}`), nil
diff --git a/internal/config/config.go b/internal/config/config.go
index 4963694..bf0554f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -129,18 +129,18 @@ func (c Config) Save(path string) error {
return fmt.Errorf("create temporary config: %w", err)
}
tmpName := tmp.Name()
- defer os.Remove(tmpName)
+ defer func() { _ = os.Remove(tmpName) }()
if err := tmp.Chmod(0600); err != nil {
- tmp.Close()
+ _ = tmp.Close()
return fmt.Errorf("set config permissions: %w", err)
}
if _, err := tmp.Write(data); err != nil {
- tmp.Close()
+ _ = tmp.Close()
return fmt.Errorf("write config: %w", err)
}
if err := tmp.Sync(); err != nil {
- tmp.Close()
+ _ = tmp.Close()
return fmt.Errorf("sync config: %w", err)
}
if err := tmp.Close(); err != nil {