diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 4f55356b8b3cf2094f31d6c9367dc14d2e2cf55c..527bc7b615495d8f9bab081a5d83669dc19b6c0e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -30,13 +30,13 @@ class ApplicationController < ActionController::Base
     devise_parameter_sanitizer.permit(:account_update, keys: [:username, :profile, :website, :twitter])
   end
 
-  def not_found
+  def not_found(**add)
     respond_to do |format|
       format.html do
         render 'errors/not_found', layout: 'without_sidebar', status: :not_found
       end
       format.json do
-        render json: { status: 'failed', errors: ['not_found'] }, status: :not_found
+        render json: { status: 'failed', success: false, errors: ['not_found'] }.merge(add), status: :not_found
       end
     end
     false
@@ -49,7 +49,7 @@ class ApplicationController < ActionController::Base
           render 'errors/not_found', layout: 'without_sidebar', status: :not_found
         end
         format.json do
-          render json: { status: 'failed', errors: ['not_found'] }, status: :not_found
+          render json: { status: 'failed', success: false, errors: ['not_found'] }, status: :not_found
         end
       end
 
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 2cf943f3a5f950a2112d04d5476bb9507f334fb4..c5c6ee388f94cf6e1883407e33810e8c3813a712 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -384,17 +384,19 @@ class PostsController < ApplicationController
 
     @post.update locked: true, locked_by: current_user,
                  locked_at: DateTime.now, locked_until: end_date
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def unlock
-    return not_found unless current_user.privilege? 'flag_curate'
-    return not_found unless @post.locked?
-    return not_found if @post.locked_until.nil? && !current_user.is_moderator
+    return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate'
+    return not_found(errors: ['not_locked']) unless @post.locked?
+    if @post.locked_by.is_moderator && !current_user.is_moderator
+      return not_found(errors: ['locked_by_mod'])
+    end
 
     @post.update locked: false, locked_by: nil,
                  locked_at: nil, locked_until: nil
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def feature
@@ -408,9 +410,9 @@ class PostsController < ApplicationController
 
     attr = @link.attributes_print
     AuditLog.moderator_audit(event_type: 'pinned_link_create', related: @link, user: current_user,
-                            comment: "<<PinnedLink #{attr}>>\n(using moderator tools on post)")
+                             comment: "<<PinnedLink #{attr}>>\n(using moderator tools on post)")
     flash[:success] = 'Post has been featured. Due to caching, it may take some time until the changes apply.'
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def save_draft
@@ -420,14 +422,14 @@ class PostsController < ApplicationController
     RequestContext.redis.set saved_at, DateTime.now.iso8601
     RequestContext.redis.expire key, 86_400 * 7
     RequestContext.redis.expire saved_at, 86_400 * 7
-    render json: { success: true, key: key }
+    render json: { status: 'success', success: true, key: key }
   end
 
   def delete_draft
     key = "saved_post.#{current_user.id}.#{params[:path]}"
     saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
     RequestContext.redis.del key, saved_at
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   private
diff --git a/test/controllers/posts_controller_test.rb b/test/controllers/posts_controller_test.rb
index e476ce678702b94e136435679c8da82f98f84a31..f149939b124ca0194b3e937d913ecf0fa282ebe2 100644
--- a/test/controllers/posts_controller_test.rb
+++ b/test/controllers/posts_controller_test.rb
@@ -670,4 +670,135 @@ class PostsControllerTest < ActionController::TestCase
     assert assigns(:post).comments_disabled
     assert assigns(:post).comments.all?(&:deleted?)
   end
+
+  # Lock
+
+  test 'can lock post' do
+    sign_in users(:deleter)
+    post :lock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert assigns(:post).locked_until <= 7.days.from_now
+    assert assigns(:post).locked_until >= 7.days.from_now - 1.minute
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  test 'lock requires authentication' do
+    post :lock, params: { id: posts(:question_one).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot lock' do
+    sign_in users(:standard_user)
+    post :lock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot lock locked post' do
+    sign_in users(:deleter)
+    post :lock, params: { id: posts(:locked).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot lock longer than 30 days' do
+    sign_in users(:deleter)
+    post :lock, params: { id: posts(:question_one).id, length: 60, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert assigns(:post).locked_until <= 30.days.from_now
+    assert assigns(:post).locked_until >= 30.days.from_now - 1.minute
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  test 'moderator can lock longer than 30 days' do
+    sign_in users(:moderator)
+    post :lock, params: { id: posts(:question_one).id, length: 60, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert assigns(:post).locked_until <= 60.days.from_now
+    assert assigns(:post).locked_until >= 60.days.from_now - 1.minute
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  test 'moderator can lock indefinitely' do
+    sign_in users(:moderator)
+    post :lock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_nil assigns(:post).locked_until
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  # Unlock
+
+  test 'can unlock post' do
+    sign_in users(:deleter)
+    posts(:locked).update(locked_until: 2.days.from_now)
+    post :unlock, params: { id: posts(:locked).id, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  test 'unlock requires authentication' do
+    post :unlock, params: { id: posts(:locked).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot unlock' do
+    sign_in users(:standard_user)
+    post :unlock, params: { id: posts(:locked).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot unlock unlocked post' do
+    sign_in users(:deleter)
+    post :unlock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot unlock post locked by moderator' do
+    sign_in users(:deleter)
+    posts(:locked_mod).update(locked_until: 2.days.from_now)
+    post :unlock, params: { id: posts(:locked_mod).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+    assert_equal ['locked_by_mod'], JSON.parse(response.body)['errors']
+  end
 end
diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml
index 25ac1dd327909d03773cbe268cf4e1d3a5eacd28..a207532c46a83281aed5aab8d7ec63713f5b7a81 100644
--- a/test/fixtures/posts.yml
+++ b/test/fixtures/posts.yml
@@ -127,8 +127,34 @@ locked:
     - bug
   score: 0.5
   locked: true
+  locked_by: deleter
+  locked_at: 2019-01-01T00:00:00.000000Z
+  locked_until: 2020-01-01T00:00:00.000000Z
+  user: standard_user
+  community: sample
+  category: main
+  license: cc_by_sa
+  upvote_count: 0
+  downvote_count: 0
+
+locked_mod:
+  post_type: question
+  title: LM ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0.5
+  locked: true
   locked_by: moderator
   locked_at: 2019-01-01T00:00:00.000000Z
+  locked_until: 2020-01-01T00:00:00.000000Z
   user: standard_user
   community: sample
   category: main