package membership import ( "bytes" "encoding/base64" "encoding/hex" "io" "net/http" "net/http/httptest" "path/filepath" "strconv" "testing" "time" cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/blobstore" ) // authHarness boots an in-process membershipd HTTP server in the given auth mode // with a fresh store + blob store, and seeds one active admin ("alice"). type authHarness struct { ts *httptest.Server store *Store alice cs.Identity alicePub string // hex } func newAuthHarness(t *testing.T, mode AuthMode) *authHarness { t.Helper() dir := t.TempDir() store, err := Open(filepath.Join(dir, "unibus.db")) if err != nil { t.Fatalf("open store: %v", err) } blobs, err := blobstore.New(filepath.Join(dir, "blobs")) if err != nil { t.Fatalf("open blobs: %v", err) } alice, err := cs.GenerateIdentity() if err != nil { t.Fatalf("identity: %v", err) } alicePub := hex.EncodeToString(alice.SignPub) if err := store.AddUser(alicePub, "alice", RoleAdmin); err != nil { t.Fatalf("seed admin: %v", err) } srv := NewServer(store, blobs, mode) ts := httptest.NewServer(srv) t.Cleanup(func() { ts.Close() store.Close() }) return &authHarness{ts: ts, store: store, alice: alice, alicePub: alicePub} } // signedReq builds a control-plane request signed by id, with explicit ts/nonce // so tests can force skew and replay. It signs via the same CanonicalRequest the // production client uses, so the test verifies the real wire contract. func signedReq(t *testing.T, base, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request { t.Helper() var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequest(method, base+path, rdr) if err != nil { t.Fatalf("new request: %v", err) } tss := strconv.FormatInt(ts, 10) canonical := CanonicalRequest(method, path, tss, nonce, body) sig := cs.SignEd25519(id.SignPriv, canonical) req.Header.Set(hdrPub, hex.EncodeToString(id.SignPub)) req.Header.Set(hdrTs, tss) req.Header.Set(hdrNonce, nonce) req.Header.Set(hdrSig, base64.StdEncoding.EncodeToString(sig)) return req } func do(t *testing.T, req *http.Request) (int, string) { t.Helper() resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(b) } const okPath = "/members/alice-endpoint/rooms" // always 200 with an empty list // Golden: a request signed by a registered, active identity is accepted. func TestAuthGoldenAccepted(t *testing.T) { h := newAuthHarness(t, AuthEnforce) now := time.Now().Unix() code, _ := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-golden")) if code != http.StatusOK { t.Fatalf("golden signed request should be 200, got %d", code) } } // Error path: a structurally valid signature from an identity that is NOT in the // allowlist is rejected with 401. func TestAuthUnregisteredRejected(t *testing.T) { h := newAuthHarness(t, AuthEnforce) bob, _ := cs.GenerateIdentity() now := time.Now().Unix() code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, bob, now, "nonce-bob")) if code != http.StatusUnauthorized { t.Fatalf("unregistered identity should be 401, got %d (%s)", code, body) } } // Error path: replaying a captured request (same nonce + signature) is rejected. func TestAuthReplayRejected(t *testing.T) { h := newAuthHarness(t, AuthEnforce) now := time.Now().Unix() first := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay") if code, body := do(t, first); code != http.StatusOK { t.Fatalf("first request should be 200, got %d (%s)", code, body) } // Identical ts + nonce + signature: a replay. second := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay") if code, body := do(t, second); code != http.StatusUnauthorized { t.Fatalf("replayed request should be 401, got %d (%s)", code, body) } } // Error path: a timestamp outside the ±30s window is rejected even with a valid // signature (defends against long-delayed captured requests). func TestAuthClockSkewRejected(t *testing.T) { h := newAuthHarness(t, AuthEnforce) stale := time.Now().Unix() - 120 code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, stale, "nonce-skew")) if code != http.StatusUnauthorized { t.Fatalf("clock-skewed request should be 401, got %d (%s)", code, body) } } // Error path: tampering the body after signing invalidates the signature. func TestAuthTamperedBodyRejected(t *testing.T) { h := newAuthHarness(t, AuthEnforce) now := time.Now().Unix() req := signedReq(t, h.ts.URL, "POST", "/rooms", []byte(`{"subject":"x"}`), h.alice, now, "nonce-tamper") // Swap the body for different bytes the signature does not cover. req.Body = io.NopCloser(bytes.NewReader([]byte(`{"subject":"evil"}`))) req.ContentLength = int64(len(`{"subject":"evil"}`)) code, body := do(t, req) if code != http.StatusUnauthorized { t.Fatalf("tampered body should be 401, got %d (%s)", code, body) } } // Error path: missing auth headers under enforce are rejected. func TestAuthMissingHeadersRejected(t *testing.T) { h := newAuthHarness(t, AuthEnforce) req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil) code, _ := do(t, req) if code != http.StatusUnauthorized { t.Fatalf("unsigned request under enforce should be 401, got %d", code) } } // Exemption: the health probe bypasses auth even under enforce. func TestAuthHealthExempt(t *testing.T) { h := newAuthHarness(t, AuthEnforce) req, _ := http.NewRequest("GET", h.ts.URL+"/healthz", nil) code, _ := do(t, req) if code != http.StatusOK { t.Fatalf("/healthz must be reachable without auth, got %d", code) } } // Soft mode: an unauthenticated request is logged but allowed through, so // clients can migrate without an outage. func TestAuthSoftAllowsUnauthenticated(t *testing.T) { h := newAuthHarness(t, AuthSoft) req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil) code, _ := do(t, req) if code != http.StatusOK { t.Fatalf("soft mode should allow unsigned request, got %d", code) } } // Off mode (default for legacy callers): no verification at all. func TestAuthOffNoVerification(t *testing.T) { h := newAuthHarness(t, AuthOff) req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil) code, _ := do(t, req) if code != http.StatusOK { t.Fatalf("off mode should allow unsigned request, got %d", code) } }