fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
378
consent-service/internal/services/totp_service_test.go
Normal file
378
consent-service/internal/services/totp_service_test.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTOTPGeneration tests TOTP code generation
|
||||
func TestTOTPGeneration_ValidSecret(t *testing.T) {
|
||||
// Test secret (Base32 encoded)
|
||||
secret := "JBSWY3DPEHPK3PXP" // This is "Hello!" in Base32
|
||||
|
||||
// Decode secret
|
||||
secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode secret: %v", err)
|
||||
}
|
||||
|
||||
// Generate TOTP for current time
|
||||
now := time.Now()
|
||||
counter := uint64(now.Unix()) / 30
|
||||
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
|
||||
mac := hmac.New(sha1.New, secretBytes)
|
||||
mac.Write(buf)
|
||||
hash := mac.Sum(nil)
|
||||
|
||||
// Dynamic truncation
|
||||
offset := hash[len(hash)-1] & 0x0f
|
||||
code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff
|
||||
totpCode := code % 1000000
|
||||
|
||||
// Check that code is 6 digits
|
||||
if totpCode < 0 || totpCode > 999999 {
|
||||
t.Errorf("TOTP code should be 6 digits, got %d", totpCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPGeneration_SameTimeProducesSameCode tests deterministic generation
|
||||
func TestTOTPGeneration_Deterministic(t *testing.T) {
|
||||
secret := "JBSWY3DPEHPK3PXP"
|
||||
secretBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
|
||||
fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
code1 := generateTOTPAt(secretBytes, fixedTime)
|
||||
code2 := generateTOTPAt(secretBytes, fixedTime)
|
||||
|
||||
if code1 != code2 {
|
||||
t.Errorf("Same time should produce same code: got %s and %s", code1, code2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTOTPGeneration_DifferentTimesProduceDifferentCodes tests time sensitivity
|
||||
func TestTOTPGeneration_TimeSensitive(t *testing.T) {
|
||||
secret := "JBSWY3DPEHPK3PXP"
|
||||
secretBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
|
||||
time1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time1.Add(30 * time.Second) // Next TOTP period
|
||||
|
||||
code1 := generateTOTPAt(secretBytes, time1)
|
||||
code2 := generateTOTPAt(secretBytes, time2)
|
||||
|
||||
if code1 == code2 {
|
||||
t.Error("Different TOTP periods should produce different codes")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for TOTP generation at specific time
|
||||
func generateTOTPAt(secretBytes []byte, t time.Time) string {
|
||||
counter := uint64(t.Unix()) / 30
|
||||
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
|
||||
mac := hmac.New(sha1.New, secretBytes)
|
||||
mac.Write(buf)
|
||||
hash := mac.Sum(nil)
|
||||
|
||||
offset := hash[len(hash)-1] & 0x0f
|
||||
code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff
|
||||
|
||||
return padCode(code % 1000000)
|
||||
}
|
||||
|
||||
func padCode(code uint32) string {
|
||||
s := ""
|
||||
for i := 0; i < 6; i++ {
|
||||
s = string(rune('0'+code%10)) + s
|
||||
code /= 10
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestTOTPValidation_WithDrift tests validation with clock drift allowance
|
||||
func TestTOTPValidation_WithDrift(t *testing.T) {
|
||||
secret := "JBSWY3DPEHPK3PXP"
|
||||
secretBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Generate current code
|
||||
currentCode := generateTOTPAt(secretBytes, now)
|
||||
|
||||
// Generate previous period code
|
||||
previousCode := generateTOTPAt(secretBytes, now.Add(-30*time.Second))
|
||||
|
||||
// Generate next period code
|
||||
nextCode := generateTOTPAt(secretBytes, now.Add(30*time.Second))
|
||||
|
||||
// All three should be valid for current validation (allowing 1 period drift)
|
||||
validCodes := []string{currentCode, previousCode, nextCode}
|
||||
|
||||
for _, code := range validCodes {
|
||||
isValid := validateTOTPWithDrift(secretBytes, code, now)
|
||||
if !isValid {
|
||||
t.Errorf("Code %s should be valid with drift allowance", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateTOTPWithDrift validates a TOTP code allowing for clock drift
|
||||
func validateTOTPWithDrift(secretBytes []byte, code string, now time.Time) bool {
|
||||
for _, offset := range []int{0, -1, 1} {
|
||||
t := now.Add(time.Duration(offset*30) * time.Second)
|
||||
expected := generateTOTPAt(secretBytes, t)
|
||||
if expected == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestRecoveryCodeGeneration tests recovery code format
|
||||
func TestRecoveryCodeGeneration_Format(t *testing.T) {
|
||||
// Simulate recovery code generation
|
||||
codeBytes := make([]byte, 4) // 8 hex chars = 4 bytes
|
||||
for i := range codeBytes {
|
||||
codeBytes[i] = byte(i + 1) // Deterministic for testing
|
||||
}
|
||||
code := strings.ToUpper(hex.EncodeToString(codeBytes))
|
||||
|
||||
// Check format
|
||||
if len(code) != 8 {
|
||||
t.Errorf("Recovery code should be 8 characters, got %d", len(code))
|
||||
}
|
||||
|
||||
// Check uppercase
|
||||
if code != strings.ToUpper(code) {
|
||||
t.Error("Recovery code should be uppercase")
|
||||
}
|
||||
|
||||
// Check alphanumeric (hex only contains 0-9 and A-F)
|
||||
for _, c := range code {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) {
|
||||
t.Errorf("Recovery code should only contain hex characters, found '%c'", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryCodeHashing tests that recovery codes are hashed for storage
|
||||
func TestRecoveryCodeHashing_Consistency(t *testing.T) {
|
||||
code := "ABCD1234"
|
||||
|
||||
hash1 := sha256.Sum256([]byte(code))
|
||||
hash2 := sha256.Sum256([]byte(code))
|
||||
|
||||
if hash1 != hash2 {
|
||||
t.Error("Recovery code hashing should be consistent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCodeHashing_CaseInsensitive(t *testing.T) {
|
||||
code1 := "ABCD1234"
|
||||
code2 := "abcd1234"
|
||||
|
||||
hash1 := sha256.Sum256([]byte(strings.ToUpper(code1)))
|
||||
hash2 := sha256.Sum256([]byte(strings.ToUpper(code2)))
|
||||
|
||||
if hash1 != hash2 {
|
||||
t.Error("Recovery codes should be case-insensitive when normalized to uppercase")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretGeneration tests that secrets are valid Base32
|
||||
func TestSecretGeneration_ValidBase32(t *testing.T) {
|
||||
// Simulate secret generation (20 bytes -> Base32 without padding)
|
||||
secretBytes := make([]byte, 20)
|
||||
for i := range secretBytes {
|
||||
secretBytes[i] = byte(i * 13) // Deterministic for testing
|
||||
}
|
||||
|
||||
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secretBytes)
|
||||
|
||||
// Verify it can be decoded
|
||||
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
if err != nil {
|
||||
t.Errorf("Generated secret should be valid Base32: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded) != 20 {
|
||||
t.Errorf("Decoded secret should be 20 bytes, got %d", len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
// TestQRCodeOtpauthURL tests otpauth URL format
|
||||
func TestQRCodeOtpauthURL_Format(t *testing.T) {
|
||||
issuer := "BreakPilot"
|
||||
email := "test@example.com"
|
||||
secret := "JBSWY3DPEHPK3PXP"
|
||||
period := 30
|
||||
digits := 6
|
||||
|
||||
url := "otpauth://totp/" + issuer + ":" + email +
|
||||
"?secret=" + secret +
|
||||
"&issuer=" + issuer +
|
||||
"&algorithm=SHA1" +
|
||||
"&digits=" + string(rune('0'+digits)) +
|
||||
"&period=" + string(rune('0'+period/10)) + string(rune('0'+period%10))
|
||||
|
||||
// Check URL starts with otpauth://totp/
|
||||
if !strings.HasPrefix(url, "otpauth://totp/") {
|
||||
t.Error("OTP auth URL should start with otpauth://totp/")
|
||||
}
|
||||
|
||||
// Check contains required parameters
|
||||
if !strings.Contains(url, "secret=") {
|
||||
t.Error("OTP auth URL should contain secret parameter")
|
||||
}
|
||||
if !strings.Contains(url, "issuer=") {
|
||||
t.Error("OTP auth URL should contain issuer parameter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChallengeExpiry tests 2FA challenge expiration
|
||||
func TestChallengeExpiry_Logic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiryMins int
|
||||
usedAfter int // minutes after creation
|
||||
shouldAllow bool
|
||||
}{
|
||||
{
|
||||
name: "challenge used within expiry",
|
||||
expiryMins: 5,
|
||||
usedAfter: 2,
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "challenge used at expiry",
|
||||
expiryMins: 5,
|
||||
usedAfter: 5,
|
||||
shouldAllow: false, // Expired
|
||||
},
|
||||
{
|
||||
name: "challenge used after expiry",
|
||||
expiryMins: 5,
|
||||
usedAfter: 10,
|
||||
shouldAllow: false,
|
||||
},
|
||||
{
|
||||
name: "challenge used immediately",
|
||||
expiryMins: 5,
|
||||
usedAfter: 0,
|
||||
shouldAllow: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.usedAfter < tt.expiryMins
|
||||
|
||||
if isValid != tt.shouldAllow {
|
||||
t.Errorf("Expected allow=%v for challenge used after %d mins (expiry: %d mins)",
|
||||
tt.shouldAllow, tt.usedAfter, tt.expiryMins)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryCodeOneTimeUse tests that recovery codes can only be used once
|
||||
func TestRecoveryCodeOneTimeUse(t *testing.T) {
|
||||
initialCodes := []string{
|
||||
sha256Hash("CODE0001"),
|
||||
sha256Hash("CODE0002"),
|
||||
sha256Hash("CODE0003"),
|
||||
}
|
||||
|
||||
// Use CODE0002
|
||||
usedCodeHash := sha256Hash("CODE0002")
|
||||
|
||||
// Remove used code from list
|
||||
var remainingCodes []string
|
||||
for _, code := range initialCodes {
|
||||
if code != usedCodeHash {
|
||||
remainingCodes = append(remainingCodes, code)
|
||||
}
|
||||
}
|
||||
|
||||
if len(remainingCodes) != 2 {
|
||||
t.Errorf("Should have 2 remaining codes after using one, got %d", len(remainingCodes))
|
||||
}
|
||||
|
||||
// Verify used code is not in remaining
|
||||
for _, code := range remainingCodes {
|
||||
if code == usedCodeHash {
|
||||
t.Error("Used recovery code should be removed from list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hash(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// TestTwoFactorEnableFlow tests the 2FA enable workflow
|
||||
func TestTwoFactorEnableFlow_States(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initialState bool // verified
|
||||
action string
|
||||
expectedState bool
|
||||
}{
|
||||
{
|
||||
name: "fresh user - not verified",
|
||||
initialState: false,
|
||||
action: "none",
|
||||
expectedState: false,
|
||||
},
|
||||
{
|
||||
name: "user verifies 2FA",
|
||||
initialState: false,
|
||||
action: "verify",
|
||||
expectedState: true,
|
||||
},
|
||||
{
|
||||
name: "already verified - stays verified",
|
||||
initialState: true,
|
||||
action: "verify",
|
||||
expectedState: true,
|
||||
},
|
||||
{
|
||||
name: "user disables 2FA",
|
||||
initialState: true,
|
||||
action: "disable",
|
||||
expectedState: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := tt.initialState
|
||||
|
||||
switch tt.action {
|
||||
case "verify":
|
||||
state = true
|
||||
case "disable":
|
||||
state = false
|
||||
}
|
||||
|
||||
if state != tt.expectedState {
|
||||
t.Errorf("Expected state=%v after action '%s', got state=%v",
|
||||
tt.expectedState, tt.action, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user