package authkit import ( "context" "encoding/hex" "net/http" "net/http/httptest" "strings" "testing" ) // --- Group 1: ExtractAPIKey --- func TestExtractAPIKey_XHeader(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set("X-API-Key", "mykey") if got := ExtractAPIKey(r); got != "mykey" { t.Fatalf("got %q want %q", got, "mykey") } } func TestExtractAPIKey_Bearer(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set("Authorization", "Bearer bearerkey") if got := ExtractAPIKey(r); got != "bearerkey" { t.Fatalf("got %q want %q", got, "bearerkey") } } func TestExtractAPIKey_XHeaderWinsOverBearer(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set("X-API-Key", "xkey") r.Header.Set("Authorization", "Bearer bearerkey") if got := ExtractAPIKey(r); got != "xkey" { t.Fatalf("got %q want %q", got, "xkey") } } func TestExtractAPIKey_None(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) if got := ExtractAPIKey(r); got != "" { t.Fatalf("got %q want empty", got) } } func TestExtractAPIKey_NonBearer(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") if got := ExtractAPIKey(r); got != "" { t.Fatalf("got %q want empty", got) } } // --- Group 2: ConstantTimeEqualString --- func TestConstantTimeEqualString_Match(t *testing.T) { if !ConstantTimeEqualString("abc", "abc") { t.Fatal("expected true") } } func TestConstantTimeEqualString_MismatchSameLen(t *testing.T) { if ConstantTimeEqualString("abc", "abd") { t.Fatal("expected false") } } func TestConstantTimeEqualString_DifferentLen(t *testing.T) { if ConstantTimeEqualString("abc", "abcd") { t.Fatal("expected false") } } // --- Group 3: ConstantTimeEqualHashedKey --- func TestConstantTimeEqualHashedKey_Match(t *testing.T) { plain := "sm_" + strings.Repeat("ff", 24) h := HashKey(plain) if !ConstantTimeEqualHashedKey(plain, h) { t.Fatal("expected true") } } func TestConstantTimeEqualHashedKey_BadHex(t *testing.T) { if ConstantTimeEqualHashedKey("anything", "notvalidhex!!") { t.Fatal("expected false on bad hex") } } // --- Group 4: GenerateAPIKey --- func TestGenerateAPIKey_Format(t *testing.T) { for _, svc := range []string{"bot", "server-manager", "portal", "addon", "mc-wrapper", "gate-waker", "cloud-svc"} { pt, h, pfx, err := GenerateAPIKey(svc) if err != nil { t.Fatalf("service %q: %v", svc, err) } // plaintext = _<48hex> idx := strings.Index(pt, "_") if idx < 0 { t.Fatalf("service %q: no underscore in %q", svc, pt) } hexPart := pt[idx+1:] if len(hexPart) != 48 { t.Fatalf("service %q: hex part len=%d want 48", svc, len(hexPart)) } if _, err := hex.DecodeString(hexPart); err != nil { t.Fatalf("service %q: hex part not valid hex: %v", svc, err) } // hash = sha256(plaintext) if h != HashKey(pt) { t.Fatalf("service %q: hash mismatch", svc) } // prefix = plaintext[:8] if pfx != pt[:8] { t.Fatalf("service %q: prefix %q want %q", svc, pfx, pt[:8]) } } } func TestGenerateAPIKey_UnknownService(t *testing.T) { _, _, _, err := GenerateAPIKey("nonexistent") if err == nil { t.Fatal("expected error for unknown service") } } // --- Group 5: DefaultScopes + HasScope --- func TestDefaultScopes_Golden(t *testing.T) { cases := []struct { service string want []string }{ {"bot", []string{"user:*", "login:manage", "kick", "events:subscribe", "keys:read"}}, {"server-manager", []string{"events:publish", "keys:provision", "keys:read"}}, {"portal", []string{"user:read", "server:read"}}, {"gate-waker", []string{"user:read", "server:read"}}, {"addon", []string{"user:read", "user:stats", "server:read", "player:disconnect", "login:request", "events:subscribe"}}, {"mc-wrapper", []string{"user:read", "server:read", "server:state", "events:subscribe"}}, } for _, tc := range cases { got := DefaultScopes(tc.service) if len(got) != len(tc.want) { t.Fatalf("service %q: scopes=%v want=%v", tc.service, got, tc.want) } for i, s := range got { if s != tc.want[i] { t.Fatalf("service %q scope[%d]: got %q want %q", tc.service, i, s, tc.want[i]) } } } } func TestDefaultScopes_Unknown(t *testing.T) { if DefaultScopes("unknown") != nil { t.Fatal("expected nil for unknown service") } } func TestHasScope_ExactMatch(t *testing.T) { if !HasScope([]string{"server:read", "kick"}, "kick") { t.Fatal("expected true") } } func TestHasScope_WildcardMatch(t *testing.T) { if !HasScope([]string{"user:*"}, "user:read") { t.Fatal("user:* should match user:read") } if !HasScope([]string{"user:*"}, "user:stats") { t.Fatal("user:* should match user:stats") } } func TestHasScope_WildcardNoMatch(t *testing.T) { if HasScope([]string{"user:*"}, "server:read") { t.Fatal("user:* should not match server:read") } } func TestHasScope_NotPresent(t *testing.T) { if HasScope([]string{"server:read"}, "kick") { t.Fatal("expected false") } } // --- Group 6: RequireScope middleware --- func makeResolver(c *Caller, err error) KeyResolver { return func(_ context.Context, _ string) (*Caller, error) { return c, err } } func TestRequireScope_MissingKey(t *testing.T) { h := RequireScope("kick", makeResolver(&Caller{Scopes: []string{"kick"}}, nil), nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Fatalf("got %d want 401", w.Code) } if !strings.Contains(w.Body.String(), "missing api key") { t.Fatalf("body: %q", w.Body.String()) } } func TestRequireScope_InvalidKey(t *testing.T) { h := RequireScope("kick", makeResolver(nil, nil), nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set("X-API-Key", "bad") h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Fatalf("got %d want 401", w.Code) } if !strings.Contains(w.Body.String(), "invalid api key") { t.Fatalf("body: %q", w.Body.String()) } } func TestRequireScope_ForbiddenScope(t *testing.T) { caller := &Caller{Service: "addon", Scopes: []string{"user:read"}} h := RequireScope("kick", makeResolver(caller, nil), nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set("X-API-Key", "somekey") h.ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Fatalf("got %d want 403", w.Code) } if !strings.Contains(w.Body.String(), "missing scope kick") { t.Fatalf("body: %q", w.Body.String()) } } func TestRequireScope_OK(t *testing.T) { caller := &Caller{Service: "bot", Scopes: []string{"kick"}} var ctxCaller *Caller next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctxCaller, _ = CallerFromContext(r.Context()) w.WriteHeader(200) }) h := RequireScope("kick", makeResolver(caller, nil), nil, next) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set("X-API-Key", "validkey") h.ServeHTTP(w, r) if w.Code != 200 { t.Fatalf("got %d want 200", w.Code) } if ctxCaller == nil || ctxCaller.Service != "bot" { t.Fatal("caller not stored in context") } } func TestRequireScope_ResolverError(t *testing.T) { h := RequireScope("kick", makeResolver(nil, context.DeadlineExceeded), nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set("X-API-Key", "somekey") h.ServeHTTP(w, r) if w.Code != http.StatusInternalServerError { t.Fatalf("got %d want 500", w.Code) } if !strings.Contains(w.Body.String(), "internal error") { t.Fatalf("body: %q", w.Body.String()) } } // --- Group 7: Cross-language vector --- const crossLangVector = "8888239ebe0105baac4d0e32428b4ea868d4fe504ce374feae74a15326087925" func TestCrossLanguageVector(t *testing.T) { input := "mcw_" + strings.Repeat("ab", 24) got := HashKey(input) t.Logf("cross-language vector: HashKey(%q) = %s", input, got) if got != crossLangVector { t.Fatalf("got %s want %s", got, crossLangVector) } }