File Coverage

lib/Concierge/Sessions/File.pm
Criterion Covered Total %
statement 122 158 77.2
branch 45 88 51.1
condition 9 21 42.8
subroutine 13 13 100.0
pod 6 7 85.7
total 195 287 67.9


line stmt bran cond sub pod time code
1             package Concierge::Sessions::File v0.11.0;
2 4     4   53 use v5.36;
  4         12  
3              
4 4     4   21 use parent 'Concierge::Sessions::Base';
  4         8  
  4         31  
5              
6 4     4   279 use File::Spec;
  4         8  
  4         142  
7 4     4   17 use Carp qw(croak);
  4         5  
  4         226  
8 4     4   28 use JSON::PP;
  4         6  
  4         8566  
9              
10             sub new {
11 5     5 0 18 my ($class, %args) = @_;
12 5         44 my $self = $class->SUPER::new(%args);
13 5   50     30 $self->{storage_dir} = $args{storage_dir} || '/tmp/sessions';
14              
15 5 100       235 unless (-d $self->{storage_dir}) {
16 4 50       494 unless (mkdir $self->{storage_dir}) {
17 0         0 croak "Failed to create storage directory '$self->{storage_dir}': $!";
18             }
19             }
20              
21 5 50       141 unless (chmod 0700, $self->{storage_dir}) {
22 0         0 croak "Failed to set permissions on storage directory '$self->{storage_dir}': $!";
23             }
24              
25 5         33 return $self;
26             }
27              
28             sub create_session {
29 5     5 1 13 my ($self, %args) = @_;
30              
31             return { success => 0, message => "Cannot create session without user_id" }
32 5 50       19 unless $args{user_id};
33              
34 5         9 my $user_id = $args{user_id};
35              
36             # Delete any existing sessions for this user (enforce single session per user)
37 5         23 $self->delete_user_session($user_id);
38              
39 5         40 my $session_id = $self->generate_session_id();
40              
41 5         231 my $session_file = File::Spec->catfile($self->{storage_dir}, $session_id);
42              
43             # Write over session file if it already exists (unlikely)
44 5         11 my $fh;
45 5 50       653 unless (open $fh, '>', $session_file) {
46 0         0 return { success => 0, message => "Cannot create session file: $!" };
47             }
48 5 50       115 unless (chmod 0600, $session_file) {
49 0         0 close $fh;
50 0         0 return { success => 0, message => "Cannot set session file permissions: $!" };
51             }
52              
53             # Build session_info structure
54 5         14 my $now = time();
55              
56             # Handle session timeout: 'indefinite' or numeric value in seconds
57 5   33     18 my $timeout = $args{session_timeout} || $self->{session_timeout};
58 5         8 my $expiration;
59 5 50 33     33 if (defined $timeout && $timeout eq 'indefinite') {
60 0         0 $expiration = 'indefinite';
61             } else {
62 5         8 $expiration = $now + $timeout;
63             }
64              
65 5   100     23 my $data = $args{data} || {}; # for app data
66 5         47 my $session_info = {
67             session_id => $session_id,
68             user_id => $user_id,
69             created_at => $now,
70             expires_at => $expiration,
71             last_updated => $now,
72             session_timeout => $timeout,
73             status => {
74             state => 'active',
75             dirty => 0,
76             },
77             data => $data,
78             };
79              
80             # Encode to JSON with pretty formatting and write with trailing newline
81 5         48 my $json = JSON::PP->new->utf8->pretty->encode($session_info);
82 5 50       2556 unless (print $fh $json, "\n") {
83 0         0 close $fh;
84 0         0 return { success => 0, message => "Cannot write to session file: $!" };
85             }
86              
87 5 50       286 unless (close $fh) {
88 0         0 return { success => 0, message => "Cannot close session file: $!" };
89             }
90              
91 5         71 return { success => 1, session_id => $session_id };
92             }
93              
94             sub get_session_info {
95 10     10 1 22 my ($self, $session_id) = @_;
96              
97 10 50       22 unless ($session_id) {
98 0         0 return { success => 0, message => "Session ID required to retrieve session from File backend" };
99             }
100              
101 10         152 my $session_file = File::Spec->catfile($self->{storage_dir}, $session_id);
102              
103 10 50       207 unless (-f $session_file) {
104 0         0 return { success => 0, message => "Session file not found" };
105             }
106              
107 10         20 my $fh;
108 10 50       309 unless (open $fh, '<', $session_file) {
109 0         0 return { success => 0, message => "Cannot read session file: $!" };
110             }
111              
112             # Read entire file (pretty JSON spans multiple lines)
113 10         47 local $/;
114 10         196 my $json = <$fh>;
115 10 50       92 unless (close $fh) {
116 0         0 return { success => 0, message => "Error closing session file: $!" };
117             }
118 10 50       23 unless (defined $json) {
119 0         0 close $fh;
120 0         0 return { success => 0, message => "Session file is empty" };
121             }
122              
123             # Decode JSON
124 10         14 my $session_info;
125 10         15 eval {
126 10         65 $session_info = JSON::PP->new->utf8->decode($json);
127             };
128 10 50       24585 if ($@) {
129 0         0 return { success => 0, message => "Invalid JSON in session file: $@" };
130             }
131              
132 10 50 33     73 unless ($session_info->{session_id} && $session_info->{created_at} && $session_info->{expires_at}) {
      33        
133 0         0 return { success => 0, message => "Invalid session file: missing system status fields" };
134             }
135              
136             # Check expiration (skip if indefinite)
137 10 50 33     68 if ($session_info->{expires_at} ne 'indefinite' && time() > $session_info->{expires_at}) {
138 0         0 return { success => 0, message => "Session expired" };
139             }
140              
141             return {
142 10         104 success => 1,
143             message => "Session info retrieved",
144             info => $session_info
145             };
146             }
147              
148             sub update_session {
149 2     2 1 5 my ($self, $session_id, $updates) = @_;
150              
151 2 50       11 unless ($session_id) {
152 0         0 return { success => 0, message => "Session ID required to update session in File backend" };
153             }
154              
155 2 50       5 unless ($updates) {
156 0         0 return { success => 1, message => "No updates specified for File backend session update" };
157             }
158              
159 2         31 my $session_file = File::Spec->catfile($self->{storage_dir}, $session_id);
160 2         6 my $fh;
161 2 50       99 unless (open $fh, '+<', $session_file) {
162 0         0 return { success => 0, message => "Cannot open or update session file: $!" };
163             }
164              
165             # Read entire file (pretty JSON spans multiple lines)
166 2         11 local $/;
167 2         49 my $json = <$fh>;
168 2         6 my $session_info;
169 2 50       7 if ($json) {
170 2         4 eval {
171 2         15 $session_info = JSON::PP->new->utf8->decode($json);
172             };
173 2 50       6892 if ($@) {
174 0         0 return { success => 0, message => "Invalid JSON in session file: $@" };
175             }
176             }
177              
178             # Apply updates
179 2 50       8 if (exists $updates->{data}) {
180 2   50     9 $session_info->{data} = $updates->{data} || {};
181             }
182              
183 2 50       7 if (exists $updates->{expires_at}) {
184 2         4 $session_info->{expires_at} = $updates->{expires_at};
185             }
186              
187             # Always update last_updated timestamp
188 2         6 $session_info->{last_updated} = time();
189              
190             # Encode to JSON with pretty formatting and write to file with trailing newline
191 2         11 my $new_json = JSON::PP->new->utf8->pretty->encode($session_info);
192 2         2089 $fh->truncate(0);
193 2         208 seek $fh, 0, 0;
194 2 50       14 unless (print $fh $new_json, "\n") {
195 0         0 close $fh;
196 0         0 return { success => 0, message => "Cannot write to session file: $!" };
197             }
198              
199 2 50       324 unless (close $fh) {
200 0         0 return { success => 0, message => "Cannot close session file: $!" };
201             }
202              
203 2         40 return { success => 1 };
204             }
205              
206             sub delete_session {
207 1     1 1 3 my ($self, $session_id) = @_;
208              
209 1 50       4 unless ($session_id) {
210 0         0 return { success => 0, message => "Session ID required to delete session from File backend" };
211             }
212              
213 1         13 my $session_file = File::Spec->catfile($self->{storage_dir}, $session_id);
214              
215 1 50       27 unless (-f $session_file) {
216 0         0 return { success => 1, message => "Session file not found to delete" };
217             }
218              
219 1 50       103 unless (unlink $session_file) {
220 0         0 return { success => 0, message => "Cannot delete session file: $!" };
221             }
222              
223 1         7 return { success => 1 };
224             }
225              
226             sub cleanup_sessions {
227 1     1 1 3 my ($self) = @_;
228              
229 1         3 my $dh;
230 1 50       36 unless (opendir($dh, $self->{storage_dir})) {
231 0         0 return { success => 0, message => "Cannot open sessions directory: $!" };
232             }
233              
234 1         2 my $deleted_count = 0;
235 1         2 my $active = [];
236 1         20 while (my $file = readdir($dh)) {
237 4 100       12 next if $file =~ /^\.\.?$/; # Skip . and ..
238             # Skip any files in the dir with suffixes, which session files don't have
239 2 50       6 next if $file =~ /\.\w{1,6}$/;
240             # Session files are named after session_ids, so this works:
241 2         4 my $get_result = $self->get_session_info($file);
242 2 50       7 if ($get_result->{success}) {
243 2         25 push $active->@* => $file;
244             }
245             else {
246             # Session is either expired or invalid, delete the file
247 0         0 my $delete_result = $self->delete_session($file);
248 0 0       0 if ($delete_result->{success}) {
249 0         0 $deleted_count++;
250             }
251             }
252             }
253              
254 1 50       12 unless (closedir $dh) {
255 0         0 return { success => 0, message => "Error closing sessions directory: $!" };
256             }
257              
258 1         9 return { success => 1, deleted_count => $deleted_count, active => $active };
259             }
260              
261             sub delete_user_session {
262 5     5 1 11 my ($self, $user_id) = @_;
263              
264 5 50       14 unless ($user_id) {
265 0         0 return { success => 0, message => "user_id required to delete user sessions from File backend" };
266             }
267              
268 5 50       240 opendir(my $dh, $self->{storage_dir}) or return {
269             success => 0,
270             message => "Cannot open sessions directory: $!"
271             };
272              
273 5         12 my $deleted_count = 0;
274              
275 5         94 while (my $file = readdir($dh)) {
276 11 100       85 next if $file =~ /^\.\.?$/; # Skip . and ..
277 1 50       5 next if $file =~ /\.\w{1,6}$/; # Skip files with extensions (not session files)
278              
279 1         13 my $session_file = File::Spec->catfile($self->{storage_dir}, $file);
280              
281             # Read and parse to check user_id
282 1 50       29 open my $fh, '<', $session_file or next;
283 1         4 local $/;
284 1         17 my $json = <$fh>;
285 1         8 close $fh;
286              
287 1 50       3 next unless defined $json;
288              
289 1         2 my $session_info;
290 1         20 eval {
291 1         7 $session_info = JSON::PP->new->utf8->decode($json);
292             };
293 1 50       1798 next if $@; # Skip invalid files
294              
295             # Delete if user_id matches
296 1 50       26 if ($session_info->{user_id} eq $user_id) {
297 0 0       0 if (unlink $session_file) {
298 0         0 $deleted_count++;
299             }
300             }
301             }
302              
303 5         52 closedir($dh);
304              
305 5         55 return { success => 1, deleted_count => $deleted_count };
306             }
307              
308             sub DESTROY {
309 5     5   3885 my ($self) = @_;
310             }
311              
312             1;
313              
314             __END__