Files
member-console/internal/workflows/oauth_token_provider_test.go
T
cgalo5758 b245bdb0a9 Fix security audit findings from 2026-09-21 scan
Remediate six confirmed security issues: deployment-only config keys,
bounded provider responses, short-lived registration sessions, private
init file mode, FedWiki workflow authorization, and switch preview
gates.

- Add DeploymentOnly config key declaration; refuse runtime overrides
  for keys that decide where secrets are sent
- Create httplimit package; bound all provider response reads at 8 MiB
- Set fifteen-minute deadline on /register sessions
- Write mc-config.yaml with 0600 permissions
- Derive FedWiki workflow IDs from site IDs; re-authorize sites before
  mutating activities
- Apply switch authorization gates to the proration preview
2026-09-21 13:57:57 -05:00

109 lines
3.6 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/httplimit"
)
func newTokenServer(t *testing.T, calls *int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*calls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"tok-` + string(rune('0'+*calls)) + `","expires_in":300,"token_type":"Bearer"}`))
}))
}
// The cached expiry must carry no monotonic clock reading, so that the
// validity check runs on the wall clock. Go's monotonic clock stops while
// the host sleeps; a monotonic comparison would keep a token past its exp
// after every resume.
func TestOAuthTokenProviderExpiryIsWallClock(t *testing.T) {
calls := 0
srv := newTokenServer(t, &calls)
defer srv.Close()
p, err := NewOAuthTokenProvider(OAuthTokenProviderConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"})
if err != nil {
t.Fatal(err)
}
if _, err := p.GetHeaders(context.Background()); err != nil {
t.Fatal(err)
}
// Round(0) strips a monotonic reading; a time that already lacks one is
// unchanged, so equality here means the stored expiry has none.
if p.expiresAt != p.expiresAt.Round(0) {
t.Fatalf("expiresAt carries a monotonic reading: %v", p.expiresAt)
}
if until := time.Until(p.expiresAt); until < 4*time.Minute || until > 5*time.Minute {
t.Fatalf("expiresAt not about five minutes out: %v", until)
}
}
// A token inside the safety buffer is refetched; one outside it is reused.
func TestOAuthTokenProviderRefreshesInsideSafetyBuffer(t *testing.T) {
calls := 0
srv := newTokenServer(t, &calls)
defer srv.Close()
p, err := NewOAuthTokenProvider(OAuthTokenProviderConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"})
if err != nil {
t.Fatal(err)
}
first, err := p.GetHeaders(context.Background())
if err != nil {
t.Fatal(err)
}
again, err := p.GetHeaders(context.Background())
if err != nil {
t.Fatal(err)
}
if calls != 1 || first[authHeaderKey] != again[authHeaderKey] {
t.Fatalf("fresh token was not reused: calls=%d", calls)
}
// Simulate the wall clock reaching the buffer (as it does after a
// suspend): the stored expiry is a wall-clock instant, so moving it is
// what the passage of wall time looks like to the check.
p.expiresAt = time.Now().Round(0).Add(p.safetyBuffer - time.Second)
third, err := p.GetHeaders(context.Background())
if err != nil {
t.Fatal(err)
}
if calls != 2 || third[authHeaderKey] == first[authHeaderKey] {
t.Fatalf("token inside the safety buffer was not refreshed: calls=%d", calls)
}
}
// A token endpoint that streams past the shared bound is refused with the
// bound's own error (security-audit-remediation-3 design D5).
func TestOAuthTokenProviderOverBoundResponseIsRefused(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
chunk := []byte(strings.Repeat(" ", 64*1024))
for written := int64(0); written <= httplimit.DefaultResponseBytes; written += int64(len(chunk)) {
if _, err := w.Write(chunk); err != nil {
return
}
}
}))
defer srv.Close()
p, err := NewOAuthTokenProvider(OAuthTokenProviderConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"})
if err != nil {
t.Fatal(err)
}
_, err = p.GetHeaders(context.Background())
if !httplimit.IsTooLarge(err) {
t.Fatalf("want the bounded reader's refusal, got %v", err)
}
}