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>
379 lines
9.7 KiB
Go
379 lines
9.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|