orama/core/pkg/serverless/hostfuncs_test.go
anonpenguin23 3b8139802c feat: APNs silent-drop guard + persistent-WS mid-session JWT refresh
#348 - APNs silent-drop guard
Apple's APNs silently returns HTTP 200 for pushes with no visible
content (no title, no body, no badge, no sound, no
content-available=1) and then drops them — which looked to the WASM
caller like a successful delivery. Now rejected up-front with the new
push.ErrEmptyContent sentinel, and the APNs provider returns the
structured push.PushError shape (HTTPStatus, Reason, Unregistered,
Wrapped) so the dispatcher can branch on Unregistered to remove dead
tokens automatically. Legacy ErrDeviceUnregistered sentinel is
preserved for errors.Is compatibility (wrapped inside PushError).

Always logs APNs HTTP response (status, reason, apns_id, token prefix)
so future silent-drop classes show up in operator logs.

content-available is also now correctly mapped from snake_case
Data["content_available"] (any truthy variant) into Apple's
canonical "content-available": 1 inside the aps dictionary.

#321 - mid-session JWT refresh on persistent WS
Long-lived persistent WS connections used to have to close+reconnect
when the JWT rolled — losing per-instance state, message queues, and
subscriptions. The handler now accepts an "auth.refresh" control
frame: client sends the new token, the gateway re-verifies it via
the new JWTVerifier interface, updates the per-instance invCtx
in-place (persistent.Instance.UpdateInvCtx), and acks. No close, no
state loss.

JWTVerifier is optional — handlers set it via SetJWTVerifier at
gateway init. When unwired the handler nack's with a "not supported
on this gateway" response and clients fall back to the old
close+reconnect path, so older deploys don't break.

Other:
- push/dispatcher.go: SendToUserDetailed returns per-device PushError
  shape so callers can act on Unregistered / HTTPStatus / Reason.
- serverless/hostfunctions/push.go: WASM host functions for the new
  detailed-error shape.
- serverless/persistent/instance.go: UpdateInvCtx mid-session.

Tests:
- ws_persistent_control_test.go: auth.refresh ack/nack paths.
- apns_test.go: empty-content rejection, PushError shape on 410 +
  generic non-200, content-available mapping.
- dispatcher_detailed_test.go: SendToUserDetailed result shape.
- instance_update_invctx_test.go: invCtx update is per-instance, not
  cross-tenant.

VERSION bumped to 0.122.27.
2026-05-19 18:19:21 +03:00

192 lines
5.4 KiB
Go

package serverless
import (
"context"
"testing"
"time"
"go.uber.org/zap"
)
func TestHostFunctions_Cache(t *testing.T) {
// Note: HostFunctions implementation has been moved to pkg/serverless/hostfunctions
// This test validates that the HostServices interface works correctly
db := NewMockRQLite()
ipfs := NewMockIPFSClient()
logger := zap.NewNop()
// Create a mock implementation that satisfies HostServices
var h HostServices = &mockHostServices{
db: db,
ipfs: ipfs,
logger: logger,
logs: make([]LogEntry, 0),
}
ctx := context.Background()
// Test Storage interface
cid, err := h.StoragePut(ctx, []byte("data"))
if err != nil {
t.Fatalf("StoragePut failed: %v", err)
}
data, err := h.StorageGet(ctx, cid)
if err != nil {
t.Fatalf("StorageGet failed: %v", err)
}
if string(data) != "data" {
t.Errorf("expected 'data', got %q", string(data))
}
}
// mockHostServices is a minimal mock for testing the HostServices interface
type mockHostServices struct {
db *MockRQLite
ipfs *MockIPFSClient
logger *zap.Logger
logs []LogEntry
}
func (m *mockHostServices) DBQuery(ctx context.Context, query string, args []interface{}) ([]byte, error) {
return nil, nil
}
func (m *mockHostServices) DBExecute(ctx context.Context, query string, args []interface{}) (int64, error) {
return 0, nil
}
func (m *mockHostServices) DBExecuteV2(ctx context.Context, query string, args []interface{}) ([]byte, error) {
return []byte(`{"rows_affected":0}`), nil
}
func (m *mockHostServices) DBQueryV2(ctx context.Context, query string, args []interface{}) ([]byte, error) {
return []byte(`{"rows":[]}`), nil
}
func (m *mockHostServices) DBQueryBatch(ctx context.Context, opsJSON []byte) ([]byte, error) {
return []byte(`{"results":[]}`), nil
}
func (m *mockHostServices) CacheGet(ctx context.Context, key string) ([]byte, error) {
return nil, nil
}
func (m *mockHostServices) CacheSet(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
return nil
}
func (m *mockHostServices) CacheDelete(ctx context.Context, key string) error {
return nil
}
func (m *mockHostServices) CacheIncr(ctx context.Context, key string) (int64, error) {
return 0, nil
}
func (m *mockHostServices) CacheIncrBy(ctx context.Context, key string, delta int64) (int64, error) {
return 0, nil
}
func (m *mockHostServices) StoragePut(ctx context.Context, data []byte) (string, error) {
// Mock implementation - just return a fake CID
return "QmTest123", nil
}
func (m *mockHostServices) StorageGet(ctx context.Context, cid string) ([]byte, error) {
// Mock implementation - return the test data
return []byte("data"), nil
}
func (m *mockHostServices) PubSubPublish(ctx context.Context, topic string, data []byte) error {
return nil
}
func (m *mockHostServices) PubSubPublishBatch(ctx context.Context, msgsJSON []byte) error {
return nil
}
func (m *mockHostServices) PushSend(ctx context.Context, userID string, msgJSON []byte) error {
return nil
}
func (m *mockHostServices) PushSendV2(ctx context.Context, userID string, msgJSON []byte) ([]byte, error) {
return []byte(`{"ok":true,"devices_attempted":0,"devices_succeeded":0,"results":[]}`), nil
}
func (m *mockHostServices) DBTransaction(ctx context.Context, opsJSON []byte) ([]byte, error) {
return []byte(`{"committed":true,"results":[]}`), nil
}
func (m *mockHostServices) ExecAndPublish(ctx context.Context, opsJSON []byte, topic string, dataTemplate []byte) ([]byte, error) {
return []byte(`{"committed":true,"published":true,"seq":1,"results":[]}`), nil
}
func (m *mockHostServices) WSPubSubBridge(ctx context.Context, clientID, topic string) error {
return nil
}
func (m *mockHostServices) WSPubSubUnbridge(ctx context.Context, clientID, topic string) error {
return nil
}
func (m *mockHostServices) WSSend(ctx context.Context, clientID string, data []byte) error {
return nil
}
func (m *mockHostServices) WSBroadcast(ctx context.Context, topic string, data []byte) error {
return nil
}
func (m *mockHostServices) FunctionInvoke(ctx context.Context, name string, payload []byte) ([]byte, error) {
return nil, nil
}
func (m *mockHostServices) HTTPFetch(ctx context.Context, method, url string, headers map[string]string, body []byte) ([]byte, error) {
return nil, nil
}
func (m *mockHostServices) GetEnv(ctx context.Context, key string) (string, error) {
return "", nil
}
func (m *mockHostServices) GetSecret(ctx context.Context, name string) (string, error) {
return "", nil
}
func (m *mockHostServices) GetRequestID(ctx context.Context) string {
return ""
}
func (m *mockHostServices) GetCallerWallet(ctx context.Context) string {
return ""
}
func (m *mockHostServices) GetWSClientID(ctx context.Context) string {
return ""
}
func (m *mockHostServices) GetCallerClaim(ctx context.Context, name string) string {
return ""
}
func (m *mockHostServices) GetCallerJWTSubject(ctx context.Context) string {
return ""
}
func (m *mockHostServices) EnqueueBackground(ctx context.Context, functionName string, payload []byte) (string, error) {
return "", nil
}
func (m *mockHostServices) ScheduleOnce(ctx context.Context, functionName string, runAt time.Time, payload []byte) (string, error) {
return "", nil
}
func (m *mockHostServices) LogInfo(ctx context.Context, message string) {
m.logs = append(m.logs, LogEntry{Level: "info", Message: message})
}
func (m *mockHostServices) LogError(ctx context.Context, message string) {
m.logs = append(m.logs, LogEntry{Level: "error", Message: message})
}